TubeSage 1.2.21

Collapsed history snapshot — represents the cumulative state of TubeSage
through 1.2.21. Earlier commit history (covering versions 1.2.0 through
1.2.20) was rewritten into this single root commit to remove tracking
of a personal deploy script that referenced local filesystem paths.

The current release content is unchanged. See manifest.json for the
plugin version. Older tags (1.2.0 through 1.2.20) have been retired.

Features in this release line:
- YouTube transcript extraction (ScrapeCreators API + local fallbacks)
- LLM-driven summarisation (OpenAI, Anthropic, Google, Ollama)
- Per-provider always-visible API key rows
- Selected-provider model controls with registry-aware overrides
- Custom-model parameter overrides with synchronous panel refresh
- Max tokens field with blur-commit and reset-on-blank
- Settings de-pollution migration on plugin load
- Summary-callout split for multi-section model output
- Security patches: fast-xml-parser >=5.7.0, uuid >=14.0.0
This commit is contained in:
Richard McCorkle 2026-05-01 15:30:50 +02:00
commit d551490a6d
50 changed files with 21969 additions and 0 deletions

23
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View file

@ -0,0 +1,23 @@
---
name: Bug report
about: Report a bug or unexpected behavior
title: "[Bug] "
labels: bug
---
**Describe the bug**
A clear and concise description of what the bug is.
**Steps to reproduce**
Steps to reproduce the behavior.
**Expected behavior**
What you expected to happen.
**Environment**
- OS:
- Plugin version:
- Obsidian version:
**Additional context**
Add any other context or screenshots here.

35
.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,35 @@
name: Release Obsidian plugin
on:
push:
tags:
- "*"
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v3
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: "18.x"
- name: Build plugin
run: |
npm install
npm run build
- name: Create release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
tag="${GITHUB_REF#refs/tags/}"
gh release create "$tag" \
--title="$tag" \
--draft \
main.js styles.css manifest.json README.md MIT-license-tubesage.md templates/YouTubeTranscript.md

49
.gitignore vendored Normal file
View file

@ -0,0 +1,49 @@
# Dependencies
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Build output
*.js.map
*.wasm
# IDE and editor file
.idea/
.vscode/
.claude
CLAUDE.md
doc/obsidian-plugin-template.md
*.swp
*.swo
.DS_Store
*.zip
# Environment variables
.env
.env.local
.env.*.local
# Logs
logs
*.log
log.md
# Testing
coverage/
# Temporary files
*.tmp
*.temp
node_modules
# Main file as this compiled
main.js
main.ts.working
Style2CSS.txt
# Chromium user data (contains browsing session data)
chromium-user-data/
# Personal deploy script — references local vault paths; not for distribution
scripts/deploy.sh

6
.npmrc Normal file
View file

@ -0,0 +1,6 @@
# Security hardening
# Disable postinstall scripts (prevents malicious scripts from running on install)
ignore-scripts=true
# Pin exact versions (no ^ auto-upgrades)
save-exact=true

21
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,21 @@
# Contributing
Thank you for your interest in improving this project!
### Issues
I welcome issues for:
- Bug reports
- Feature requests
- Documentation corrections
- General feedback or questions
Please provide as much detail as possible to help me understand and address the issue.
### Pull Requests
This project **does not accept external pull requests or code contributions at this time.**
If youd like to implement changes or improvements, feel free to fork the repository and work within your own copy.
I appreciate your understanding and support!

29
MIT-license-tubesage.md Normal file
View file

@ -0,0 +1,29 @@
# MIT License
Copyright (c) [2024] [Richard McCorkle]
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
# YouTube Content Usage Disclaimer
This software facilitates the extraction and summarization of publicly available YouTube video transcripts. Users of this software should be aware of the following:
1. **Terms of Service Considerations**: The use of automated tools to extract content from YouTube may conflict with YouTube's Terms of Service. This software is provided for educational and personal use only.
2. **Copyright Responsibility**: Users are solely responsible for ensuring their use of any extracted content complies with applicable copyright laws. The creators of this software do not endorse copyright infringement.
3. **Fair Use Guidance**: When using extracted transcripts:
- Always include proper attribution to the original source
- Consider the purpose and nature of your use (personal research, education, etc.)
- Be mindful of the amount of content used
- Consider the potential effect on the market for the original content
4. **Private Use Focus**: This tool is intended for private note-taking and personal knowledge management. Redistribution of extracted content may present additional legal considerations.
5. **No Legal Warranty**: The creators of this software make no representations or warranties regarding the legality of any particular use case. Users should consult legal counsel for specific situations.
By using this software, you acknowledge your understanding of these considerations and agree to use the software responsibly.

248
README.md Normal file
View file

@ -0,0 +1,248 @@
# TubeSage: YouTube Transcript AI for Obsidian
TubeSage is a powerful Obsidian plugin that transforms YouTube videos into comprehensive, structured notes using cutting-edge large language models (LLMs). Extract transcripts, generate intelligent summaries, and create timestamped notes that link directly back to specific moments in videos—perfect for researchers, students, and lifelong learners building knowledge in Obsidian.
## 🚀 Quick Start
### Installation
**Recommended (Community Plugins):** Install through Obsidian's Settings → Community plugins browser once TubeSage is approved in the directory.
**Manual install from a GitHub release:**
1. Open the [latest release](https://github.com/rmccorkl/TubeSage/releases/latest) page.
2. From the **Assets** section, download the individual files: `main.js`, `manifest.json`, and `styles.css`.
*Do not* download "Source code (zip)" or "Source code (tar.gz)" — those are GitHub-generated archives of the TypeScript source and will not load as a plugin.
3. In your vault, create the folder `.obsidian/plugins/tubesage/` (create `tubesage` if it doesn't exist).
4. Move the three downloaded files into that `tubesage/` folder.
5. In Obsidian, go to Settings → Community plugins, toggle off "Restricted mode" if needed, refresh the installed-plugins list, and enable **TubeSage**.
6. Accept the license terms in TubeSage's settings panel.
7. Configure API keys for your preferred LLM provider.
8. (For YouTube notes) Install the [Templater plugin](https://github.com/SilentVoid13/Templater) for template-driven formatting.
### Requirements
- [Obsidian](https://obsidian.md/) v1.2.0+
- [Templater plugin](https://github.com/SilentVoid13/Templater) (required for template functionality)
- API key for at least one LLM provider:
- OpenAI (GPT-4, GPT-4o, etc.)
- Anthropic (Claude 3 family)
- Google (Gemini Pro)
- Ollama (local models - free)
- YouTube Data API key (optional - required only for channel/playlist processing)
## ✨ Key Features
### 🎯 Core Functionality
- **Smart Transcript Extraction**: Advanced extraction from YouTube videos with multiple fallback methods
- **AI-Powered Summarization**: Generate structured summaries using state-of-the-art LLMs
- **Intelligent Timestamp Links**: Automatically add clickable links to video timestamps in section headings
- **Cross-Platform Compatibility**: Works seamlessly on desktop and mobile Obsidian
- **Batch Processing**: Process entire YouTube channels and playlists efficiently
### 🤖 Advanced LLM Integration
- **Multi-Provider Support**: OpenAI, Anthropic, Google Gemini, and Ollama
- **Smart Model Selection**: Automatically recommend optimal models based on content complexity
- **LangChain Integration**: Unified interface across all cloud providers
- **Custom Fetch Shim**: Cross-platform networking that works on mobile without Node.js dependencies
- **Performance Monitoring**: Real-time tracking of processing times and bottlenecks
### 📱 Mobile-First Design
- **Mobile Optimized**: Full functionality on iOS and Android
- **Adaptive Processing**: Smart adjustments for mobile platform limitations
- **Enhanced Error Recovery**: Robust fallback systems for mobile network conditions
- **Platform Detection**: Automatic optimization based on device capabilities
### ⚡ Performance & Reliability
- **Smart Recovery System**: Intelligent error handling with automatic retries
- **Optimized Chunking**: Efficient content processing for large transcripts
- **API Quota Management**: Intelligent handling of rate limits and quotas
- **Performance Metrics**: Detailed analytics for optimization suggestions
## 🔧 Configuration
### 1. License Acceptance
- Accept the MIT license terms before using the plugin
- View license details using the "View License" button
- Required for plugin activation
### 2. LLM Provider Setup
Choose and configure your preferred LLM provider:
#### OpenAI
- **Best for**: General-purpose summaries, structured content
- **Models**: GPT-4o (recommended), GPT-4, GPT-3.5-Turbo
- **Strengths**: Consistent formatting, excellent instruction following
#### Anthropic
- **Best for**: Nuanced analysis, complex topics, longer content
- **Models**: Claude 3 Opus (highest quality), Claude 3 Haiku (speed)
- **Strengths**: Deep understanding, contextual awareness
#### Google Gemini
- **Best for**: Factual summaries, technical content
- **Models**: Gemini Pro
- **Strengths**: Strong with data analysis, balanced performance
#### Ollama (Local)
- **Best for**: Privacy-focused users, offline processing
- **Models**: Various open-source models (Llama, Mistral, etc.)
- **Strengths**: Complete privacy, no API costs, works offline
- **Requirements**: Ollama installed and running locally
### 3. Advanced Settings
- **Summary Modes**: Fast (brief) vs. Extensive (detailed)
- **Timestamp Processing**: Enable/disable automatic timestamp link generation
- **Performance Monitoring**: Track and optimize processing performance
- **Batch Processing**: Configure sequential vs. parallel processing for collections
- **Mobile Optimization**: Adaptive settings for mobile devices
## 📋 Usage Guide
### Basic Workflow
1. **Access Plugin**: Click the YouTube icon in the ribbon or use Command Palette
2. **Enter Video URL**: Paste any YouTube video URL
3. **Configure Options**: Choose summary mode and folder location
4. **Process**: Click "Process Video" and wait for completion
5. **Review**: Your structured note will appear in the specified folder
### Batch Processing (Channels/Playlists)
1. **Setup YouTube API**: Configure YouTube Data API key in settings
2. **Enter Collection URL**: Paste channel or playlist URL
3. **Set Limits**: Choose number of videos to process
4. **Configure Processing**: Select sequential or parallel processing
5. **Monitor Progress**: Real-time updates on processing status
### Timestamp Navigation
- Each section heading includes a "[Watch]" link
- Links jump to the exact moment in the YouTube video
- Perfect for reviewing specific topics or taking detailed notes
## 🏗 Technical Architecture
TubeSage features a modern, modular architecture designed for scalability and cross-platform compatibility:
### Core Components
- **Main Plugin** (`main.ts`): Central coordinator and UI management
- **Transcript Extractor** (`src/youtube-transcript.ts`): Multi-method extraction with mobile fallbacks
- **LLM Factory** (`src/llm/llm-factory.ts`): Factory pattern for managing LLM clients
- **Transcript Summarizer** (`src/llm/transcript-summarizer.ts`): Orchestrates AI summarization
- **Timestamp Processor** (`src/utils/timestamp-utils.ts`): Intelligent timestamp link generation
### LLM Integration Layer
- **LangChain Client** (`src/llm/langchain-client.ts`): Unified interface for cloud providers
- **Provider-Specific Clients**: Optimized implementations for each LLM provider
- **Custom Fetch Shim** (`src/utils/fetch-shim.ts`): Cross-platform HTTP handling
### Utility Systems
- **Smart Recovery** (`src/utils/error-utils.ts`): Advanced error handling and retry logic
- **Performance Monitor** (`src/utils/logger.ts`): Comprehensive metrics and optimization
- **YouTube Integration** (`src/utils/youtube-utils.ts`): Video metadata and batch processing
- **Cross-Platform Utils**: Mobile-aware path handling, form validation, and more
### Architecture Diagrams
Comprehensive diagrams are available in the `/docs` directory:
- **[Workflow Diagram](docs/workflow-diagram.md)**: Complete user and system workflow
- **[Data Flow Diagram](docs/data-flow-diagram.md)**: Data movement through the system
## 🎯 Use Cases
### 📚 Academic Research
- Convert lecture videos into structured notes
- Extract key concepts with timestamp references
- Build interconnected knowledge graphs in Obsidian
### 💼 Professional Development
- Process conference talks and webinars
- Create searchable technical documentation
- Build training material libraries
### 🎓 Learning & Education
- Transform educational content into study guides
- Create timestamped reference materials
- Build comprehensive course notes
### 📖 Content Creation
- Research and outline video content
- Extract quotes and references with citations
- Build content libraries for writing projects
## 🔄 Recent Updates (v1.0.6)
### Enhanced Cross-Platform Support
- **Unified Fetch Implementation**: All LLM providers now work seamlessly on mobile
- **Improved Mobile Processing**: Better handling of iOS/Android limitations
- **Enhanced Error Recovery**: Smarter retry mechanisms for failed operations
### Performance & Reliability
- **Smart Model Selection**: AI-driven recommendations based on content complexity
- **Advanced Performance Monitoring**: Real-time metrics and optimization suggestions
- **Optimized Batch Processing**: Improved handling of channels and playlists
- **Better API Quota Management**: Intelligent rate limiting and quota tracking
### User Experience Improvements
- **Enhanced Error Messages**: More helpful diagnostics and solution suggestions
- **Improved Validation**: Better input validation with helpful feedback
- **Streamlined Configuration**: Simplified setup process for new users
- **Better Mobile UI**: Optimized interface for mobile Obsidian
## 🔐 Privacy & Security
TubeSage is designed with privacy in mind:
- **Local Processing**: Transcripts are processed locally when possible
- **Secure API Communication**: All external API calls use HTTPS
- **No Data Storage**: No user data is stored on external servers
- **Ollama Support**: Complete offline processing with local models
- **Open Source**: Full transparency with open-source codebase
## 🎨 Recommended Setup
### Obsidian Plugins
- **Templater**: Essential for note formatting (required)
- **Iconize**: Enhanced note organization with custom icons
- **Icon Shortcodes**: Support for icon shortcodes in notes
### Obsidian Settings
- Disable "Show inline title" for cleaner note appearance
- Disable "Properties view" for better performance with large notes
- Enable "Use tabs" for better navigation of multiple notes
## 🆘 Troubleshooting
### Common Issues
- **API Key Errors**: Verify API keys are correctly configured in settings
- **Mobile Processing Issues**: Ensure stable internet connection, consider using Ollama for offline processing
- **Timestamp Link Failures**: Check video availability and URL format
- **Batch Processing Limits**: Monitor YouTube API quota usage
### Performance Optimization
- Use appropriate LLM models for content length
- Enable performance monitoring to identify bottlenecks
- Consider local Ollama models for frequent processing
- Adjust chunk sizes for very long videos
## 📄 License
This project is licensed under the MIT License - see the [LICENSE](MIT-license-tubesage.md) file for details.
## 🤝 Support & Contribution
### Getting Help
- **GitHub Issues**: Report bugs and request features
- **Documentation**: Comprehensive guides in the `/docs` directory
- **Community**: Share tips and tricks with other users
### Support Development
If TubeSage enhances your learning and research workflow, consider supporting its development:
- ⭐ Star the repository on GitHub
- 🐛 Report bugs and suggest improvements
- ☕ [Buy me a coffee](https://www.buymeacoffee.com/RMcCorkle)
### Contribution Policy
- **Issues Welcome**: Bug reports and feature requests are encouraged
- **Code Contributions**: Please fork the repository for code changes
- **Documentation**: Help improve guides and examples
---
**GitHub Repository**: [https://github.com/rmccorkl/TubeSage](https://github.com/rmccorkl/TubeSage)
Transform your YouTube learning experience with TubeSage - where video content becomes structured knowledge.

1
check.sh Normal file
View file

@ -0,0 +1 @@
404: Not Found

1
docs/.nojekyll Normal file
View file

@ -0,0 +1 @@
# This file tells GitHub Pages to not process the site with Jekyll

BIN
docs/TubeSage.mp4 Normal file

Binary file not shown.

299
docs/data-flow-diagram.md Normal file
View file

@ -0,0 +1,299 @@
# Data Flow Diagram - TubeSage
This diagram illustrates the comprehensive data flow throughout the TubeSage plugin for Obsidian, showing how information moves through the system's enhanced architecture and processing pipeline.
```mermaid
%%{init: {
'theme': 'dark',
'themeVariables': {
'fontSize': '14px',
'primaryColor': '#7aa2f7',
'primaryTextColor': '#ffffff',
'primaryBorderColor': '#7aa2f7',
'lineColor': '#7aa2f7',
'secondaryColor': '#bb9af7',
'tertiaryColor': '#ff9e64'
},
'flowchart': {
'curve': 'basis',
'useMaxWidth': true,
'htmlLabels': true,
'rankSpacing': 70,
'nodeSpacing': 50
}
}}%%
flowchart TD
%% External Data Sources
YT[YouTube Platform] --> |Video HTML/JSON| ExtractorMobile[Mobile Transcript Extractor]
YT --> |Video HTML/JSON| ExtractorDesktop[Desktop Transcript Extractor]
YTAPI[YouTube Data API] --> |Channel/Playlist Data| BatchProcessor[Batch Processor]
%% Core Processing Pipeline
ExtractorMobile --> |Raw Transcript| TranscriptProcessor[Transcript Processor]
ExtractorDesktop --> |Raw Transcript| TranscriptProcessor
TranscriptProcessor --> |Cleaned Transcript| ComplexityAnalyzer[Content Complexity Analyzer]
ComplexityAnalyzer --> |Analysis Results| ModelSelector[Smart Model Selector]
%% Configuration and Settings
UserSettings[User Settings] --> |LLM Config| LLMFactory[LLM Factory]
UserSettings --> |Processing Preferences| ModelSelector
UserSettings --> |API Keys| APIManager[API Key Manager]
APIManager --> |Credentials| LLMFactory
%% LLM Factory and Clients
LLMFactory --> |Provider Selection| LangChainClient[LangChain Unified Client]
LLMFactory --> |Direct Connection| OllamaClient[Ollama Local Client]
%% Cross-Platform Fetch Infrastructure
FetchShim[Obsidian Fetch Shim] --> |HTTP Requests| OpenAIAPI[OpenAI API]
FetchShim --> |HTTP Requests| AnthropicAPI[Anthropic API]
FetchShim --> |HTTP Requests| GeminiAPI[Google Gemini API]
FetchShim --> |HTTP Requests| YTAPI
FetchShim --> |HTTP Requests| YT
%% LLM Processing Flow
ModelSelector --> |Selected Provider| LangChainClient
LangChainClient --> |API Calls via Fetch Shim| FetchShim
OllamaClient --> |Local API Calls| OllamaLocal[Local Ollama Server]
%% API Responses
OpenAIAPI --> |Completion Response| LangChainClient
AnthropicAPI --> |Completion Response| LangChainClient
GeminiAPI --> |Completion Response| LangChainClient
OllamaLocal --> |Completion Response| OllamaClient
%% Response Processing
LangChainClient --> |AI Summary| ResponseProcessor[Response Processor]
OllamaClient --> |AI Summary| ResponseProcessor
TranscriptProcessor --> |Raw Transcript| ResponseProcessor
%% Timestamp Enhancement Pipeline
ResponseProcessor --> |Summary Content| TimestampProcessor[Timestamp Processor]
TimestampProcessor --> |Document Components| ComponentExtractor[Component Extractor]
ComponentExtractor --> |Content Chunks| ChunkOptimizer[Chunk Optimizer]
ChunkOptimizer --> |Optimized Chunks| TimestampLinker[Timestamp Link Generator]
TimestampLinker --> |Enhanced Chunks| LinkValidator[Link Validator]
LinkValidator --> |Validated Content| DocumentReconstructor[Document Reconstructor]
%% Template and Note Creation
DocumentReconstructor --> |Enhanced Document| TemplateProcessor[Template Processor]
Templates[Templater Templates] --> |Template Data| TemplateProcessor
TemplateProcessor --> |Formatted Content| NoteCreator[Obsidian Note Creator]
NoteCreator --> |Final Note| ObsidianVault[Obsidian Vault]
%% Performance Monitoring System
PerformanceMonitor[Performance Monitor] --> |Metrics Collection| MetricsCollector[Metrics Collector]
TranscriptProcessor --> |Processing Time| PerformanceMonitor
ResponseProcessor --> |LLM Response Time| PerformanceMonitor
TimestampProcessor --> |Enhancement Time| PerformanceMonitor
%% Performance Analysis
MetricsCollector --> |Performance Data| BottleneckAnalyzer[Bottleneck Analyzer]
BottleneckAnalyzer --> |Analysis Results| OptimizationEngine[Optimization Engine]
OptimizationEngine --> |Suggestions| UserInterface[User Interface]
%% Error Handling and Recovery
ErrorHandler[Smart Error Handler] --> |Error Analysis| RecoverySystem[Recovery System]
ExtractorMobile --> |Extraction Errors| ErrorHandler
ExtractorDesktop --> |Extraction Errors| ErrorHandler
LangChainClient --> |API Errors| ErrorHandler
OllamaClient --> |Connection Errors| ErrorHandler
TimestampLinker --> |Generation Errors| ErrorHandler
%% Recovery Actions
RecoverySystem --> |Retry Parameters| ExtractorMobile
RecoverySystem --> |Retry Parameters| ExtractorDesktop
RecoverySystem --> |Fallback Methods| LLMFactory
RecoverySystem --> |Alternative Approaches| TimestampLinker
%% Batch Processing Data Flow
BatchProcessor --> |Video URLs| QueueManager[Processing Queue Manager]
QueueManager --> |Sequential Queue| SequentialProcessor[Sequential Processor]
QueueManager --> |Parallel Queue| ParallelProcessor[Parallel Processor]
SequentialProcessor --> |Individual Videos| TranscriptProcessor
ParallelProcessor --> |Concurrent Videos| TranscriptProcessor
%% Mobile Platform Adaptations
PlatformDetector[Platform Detector] --> |Mobile Optimizations| ExtractorMobile
PlatformDetector --> |Desktop Features| ExtractorDesktop
PlatformDetector --> |Platform Config| FetchShim
%% Data Validation and Quality
QualityChecker[Content Quality Checker] --> |Validation Results| ResponseProcessor
ResponseProcessor --> |Content Quality| QualityChecker
TimestampLinker --> |Link Quality| QualityChecker
%% Styling
style YT fill:#ff9e64,stroke:#ff9e64,color:white
style YTAPI fill:#ff9e64,stroke:#ff9e64,color:white
style OpenAIAPI fill:#f7768e,stroke:#f7768e,color:white
style AnthropicAPI fill:#f7768e,stroke:#f7768e,color:white
style GeminiAPI fill:#f7768e,stroke:#f7768e,color:white
style OllamaLocal fill:#f7768e,stroke:#f7768e,color:white
style FetchShim fill:#73daca,stroke:#73daca,color:#1a1b26
style LLMFactory fill:#73daca,stroke:#73daca,color:#1a1b26
style PerformanceMonitor fill:#73daca,stroke:#73daca,color:#1a1b26
style ErrorHandler fill:#73daca,stroke:#73daca,color:#1a1b26
style ModelSelector fill:#73daca,stroke:#73daca,color:#1a1b26
style ObsidianVault fill:#9ece6a,stroke:#9ece6a,color:#1a1b26
style LangChainClient fill:#bb9af7,stroke:#bb9af7,color:white
style OllamaClient fill:#bb9af7,stroke:#bb9af7,color:white
style TimestampProcessor fill:#bb9af7,stroke:#bb9af7,color:white
style ResponseProcessor fill:#bb9af7,stroke:#bb9af7,color:white
```
## Data Flow Architecture Overview
TubeSage's data flow architecture is designed for maximum flexibility, reliability, and cross-platform compatibility. The system processes information through multiple specialized pipelines while maintaining consistent quality and performance monitoring.
### 🔄 **Core Data Flow Patterns**
#### **1. Multi-Source Input Pipeline**
- **YouTube Platform**: Primary source for video content and transcript data
- **YouTube Data API**: Secondary source for channel/playlist metadata and video listings
- **User Settings**: Configuration data that influences all processing decisions
- **Template System**: Formatting instructions for final note generation
#### **2. Platform-Adaptive Extraction**
- **Desktop Extractor**: Full-featured extraction using standard web APIs and parsing
- **Mobile Extractor**: Optimized extraction methods designed for iOS/Android constraints
- **Intelligent Fallback**: Automatic switching between extraction methods based on success rates
- **Quality Validation**: Continuous validation of extracted content for completeness
#### **3. Unified Processing Pipeline**
- **Content Analysis**: Smart analysis of transcript complexity and length
- **Model Selection**: AI-driven selection of optimal LLM models based on content characteristics
- **Provider Abstraction**: Unified interface across multiple LLM providers
- **Quality Assurance**: Multi-stage validation of processing results
### 🌐 **Cross-Platform Infrastructure**
#### **Obsidian Fetch Shim**
The custom fetch shim serves as the foundation for all network communication:
- **Universal Compatibility**: Works identically on desktop and mobile Obsidian
- **Protocol Abstraction**: Translates between different platform networking requirements
- **Error Handling**: Comprehensive error recovery with platform-specific optimizations
- **Performance Optimization**: Intelligent caching and request optimization
#### **Platform Detection System**
Automatic adaptation based on the runtime environment:
- **Capability Detection**: Identifies available features and limitations
- **Resource Optimization**: Adjusts processing intensity based on device capabilities
- **Network Adaptation**: Optimizes request patterns for mobile network conditions
- **Storage Management**: Platform-aware temporary file and cache management
### 🤖 **Advanced LLM Integration**
#### **Factory Pattern Architecture**
The LLM Factory provides consistent access to multiple AI providers:
- **Provider Abstraction**: Unified interface regardless of underlying API differences
- **Credential Management**: Secure handling of API keys and authentication
- **Connection Pooling**: Efficient management of API connections and rate limits
- **Failover Support**: Automatic fallback to alternative providers when needed
#### **LangChain Integration**
Standardized AI processing through LangChain framework:
- **Multi-Provider Support**: OpenAI, Anthropic, and Google Gemini integration
- **Prompt Optimization**: Advanced prompt engineering for consistent results
- **Response Processing**: Standardized handling of AI responses across providers
- **Error Recovery**: Intelligent retry mechanisms with exponential backoff
#### **Smart Model Selection**
AI-driven optimization of model selection:
- **Content Analysis**: Real-time analysis of transcript complexity and topic density
- **Performance Prediction**: Historical data-based performance predictions
- **Cost Optimization**: Balance between quality and processing cost/time
- **User Preference Integration**: Respect for user preferences while suggesting optimizations
### ⚡ **Performance Monitoring System**
#### **Real-Time Metrics Collection**
Comprehensive tracking of system performance:
- **Component-Level Timing**: Separate metrics for each processing stage
- **Resource Usage**: Memory, network, and CPU utilization tracking
- **Quality Metrics**: Success rates, error frequencies, and output quality scores
- **User Experience Metrics**: Response times and user satisfaction indicators
#### **Intelligent Bottleneck Detection**
Automated identification of performance issues:
- **Pattern Recognition**: Machine learning-based detection of performance anomalies
- **Root Cause Analysis**: Automated drilling down to identify specific bottlenecks
- **Predictive Analysis**: Early warning systems for potential performance degradation
- **Optimization Recommendations**: AI-generated suggestions for performance improvements
### 🔧 **Enhanced Processing Pipelines**
#### **Transcript Processing Pipeline**
Multi-stage processing for optimal content extraction:
1. **Raw Extraction**: Platform-specific extraction with multiple fallback methods
2. **Content Cleaning**: Removal of artifacts, formatting normalization
3. **Quality Validation**: Verification of transcript completeness and accuracy
4. **Metadata Integration**: Combination with video metadata (title, description, duration)
5. **Complexity Analysis**: Assessment of content complexity for downstream processing
#### **AI Enhancement Pipeline**
Sophisticated AI processing with quality controls:
1. **Model Selection**: Smart selection based on content analysis
2. **Prompt Engineering**: Dynamic prompt optimization based on content type
3. **Response Generation**: AI processing with real-time monitoring
4. **Quality Validation**: Multi-criteria validation of AI responses
5. **Post-Processing**: Content refinement and formatting optimization
#### **Timestamp Enhancement Pipeline**
Advanced timestamp processing for navigation enhancement:
1. **Document Parsing**: Intelligent extraction of content structure
2. **Chunk Optimization**: Smart division of content for optimal processing
3. **Heading Detection**: AI-powered identification of section boundaries
4. **Link Generation**: Creation of precise YouTube timestamp links
5. **Validation System**: Comprehensive validation with retry mechanisms
6. **Document Reconstruction**: Seamless integration of enhanced content
### 🔄 **Batch Processing Architecture**
#### **Queue Management System**
Sophisticated handling of multi-video processing:
- **Priority Queuing**: Intelligent prioritization based on content type and user preferences
- **Resource Allocation**: Dynamic allocation of processing resources
- **Progress Tracking**: Real-time monitoring of batch processing progress
- **Error Isolation**: Prevention of single-video failures from affecting entire batches
#### **Parallel Processing Engine**
Optimized concurrent processing capabilities:
- **Concurrency Control**: Intelligent management of simultaneous processing threads
- **Rate Limiting**: Automatic adherence to API rate limits and quotas
- **Load Balancing**: Dynamic distribution of workload across available resources
- **Failure Recovery**: Automatic retry and recovery mechanisms for failed operations
### 🛡️ **Error Handling and Recovery**
#### **Smart Error Recovery System**
Advanced error handling with learning capabilities:
- **Error Classification**: Automatic categorization of errors by type and severity
- **Recovery Strategy Selection**: AI-driven selection of optimal recovery approaches
- **Parameter Optimization**: Dynamic adjustment of processing parameters between retries
- **Learning Integration**: Continuous improvement based on successful recovery patterns
#### **Fallback Mechanisms**
Comprehensive fallback systems for reliability:
- **Extraction Fallbacks**: Multiple extraction methods with automatic switching
- **Provider Fallbacks**: Automatic switching between LLM providers on failure
- **Processing Fallbacks**: Alternative processing approaches for edge cases
- **Quality Fallbacks**: Graceful degradation when optimal quality cannot be achieved
### 📊 **Data Validation and Quality Control**
#### **Multi-Stage Validation**
Comprehensive quality assurance throughout the pipeline:
- **Input Validation**: Verification of source data quality and completeness
- **Processing Validation**: Real-time monitoring of processing quality
- **Output Validation**: Final verification of generated content quality
- **User Feedback Integration**: Continuous improvement based on user feedback
#### **Quality Metrics System**
Sophisticated quality measurement and optimization:
- **Content Quality Scores**: AI-generated quality assessments
- **User Satisfaction Metrics**: Tracking of user satisfaction and engagement
- **Performance Benchmarks**: Continuous benchmarking against quality standards
- **Improvement Tracking**: Monitoring of quality improvements over time
This comprehensive data flow architecture ensures that TubeSage delivers consistent, high-quality results while maintaining excellent performance across all supported platforms and use cases.

View file

@ -0,0 +1,813 @@
# Restoring a “local directed” YouTube transcript fetch in a TypeScript/Obsidian codebase
## Executive summary
Your existing “local transcript” method broke because it depends on **private `youtubei` endpoints** (`/player`, `/get_transcript`) that are now returning `HTTP 400 FAILED_PRECONDITION` in your logs, making that path brittle and hard to “patch” reliably without chasing integrity/session requirements. fileciteturn0file0
The most robust local approach is to pivot to what the **watch page already exposes**: parse the `/watch?v=VIDEO_ID` HTML for the `ytInitialPlayerResponse` JSON object and read `captions.playerCaptionsTracklistRenderer.captionTracks[].baseUrl`. Multiple independent implementations and fixes in the wild rely on this exact technique. citeturn1view2turn4view1turn7view0
Once you have `baseUrl`, fetch captions via YouTubes timedtext endpoint using **`fmt=json3`** (or `fmt=vtt` if you prefer WebVTT). The `json3` payload is a structured event stream (`wireMagic: "pb3"`, `events[].tStartMs`, `events[].dDurationMs`, `events[].segs[].utf8`) that can be parsed into your `TranscriptSegment[]`. citeturn3search3turn12search5turn4view0
This report provides (a) a concrete implementation plan and (b) a dropin patch for `src/youtube-transcript.ts` to make `fetchTranscript()` reliable again, with explicit handling for language selection, translation (`tlang`), format parameter pitfalls (replace, dont append), ratelimiting, fallbacks, uservisible error messaging, and tests.
## What broke and why the old method is brittle
Your project notes show two historic “local” phases: (1) WEB `next → get_transcript` and (2) ANDROID `player → captionTracks[].baseUrl → timedtext`. Both now fail with `FAILED_PRECONDITION` (`"Precondition check failed."`) as of **20260305**. fileciteturn0file0
This failure pattern aligns with wider ecosystem evidence that YouTube frequently changes preconditions for internal API clients; for example, downloader and proxy projects report “precondition check failed” issues tied to client versions, A/B tests, or newly required tokens. citeturn11search0turn11search8turn11search2
The implication is pragmatic: treat `youtubei/*` as **experimental** and default to approaches that do not depend on internal request integrity. Your own findings already recommend moving to watchpage parsing and timedtext downloads (and making internal endpoints optin). fileciteturn0file0
A second operational reality is **throttling**: raw page/API fetches can hit `429 Too Many Requests`, and many tools work around this with deliberate slowing/backoff. citeturn2search2turn10view0
## Watch-page captions extraction design
**Core idea:** fetch the watch HTML, extract `ytInitialPlayerResponse`, read caption tracks, choose a track, then fetch captions from the tracks `baseUrl` in `json3`.
### Example objects you will parse
**`ytInitialPlayerResponse` (minimal sketch):**
```json
{
"playabilityStatus": { "status": "OK" },
"captions": {
"playerCaptionsTracklistRenderer": {
"captionTracks": [
{
"baseUrl": "https://www.youtube.com/api/timedtext?...&lang=en&v=VIDEO_ID&fmt=srv3&...",
"languageCode": "en",
"vssId": ".en",
"kind": "asr",
"isTranslatable": true
}
]
}
}
}
```
The object path `captions.playerCaptionsTracklistRenderer.captionTracks[0].baseUrl` is widely used as the entry point for downloading captions. citeturn1view2turn4view1turn7view0
Field names like `baseUrl`, `vssId`, `languageCode`, and optional `kind` are consistent with multiple open implementations and type definitions. citeturn4view0turn4view1turn0search22
**`json3` timedtext response shape (minimal sketch):**
```json
{
"wireMagic": "pb3",
"events": [
{ "tStartMs": 80, "dDurationMs": 3119, "segs": [ { "utf8": "hey" }, { "utf8": " everybody" } ] }
]
}
```
The `wireMagic: "pb3"` marker and `events[].segs[].utf8` text fragments (with perevent timing`) are documented by real payload examples and consumer code. citeturn3search3turn12search5turn4view0
### Robust extraction from watch HTML
Many “quick scripts” do brittle string splits on `ytInitialPlayerResponse = ...` and terminate on `;var` or `;</script>`. citeturn4view1turn4view2turn7view0
For a plugin you ship, the robust pattern is:
* Locate the marker: `ytInitialPlayerResponse` (handle `var ytInitialPlayerResponse =` and `ytInitialPlayerResponse =` variants). citeturn7view0turn4view1
* From the first `{` after the marker, extract a **balanced JSON object** using brace counting while respecting strings/escapes (so nested objects dont break and `}` inside strings is ignored). This is an implementation technique (not YouTubespecific), but it directly addresses the brittleness shown in splitbased examples. citeturn4view1turn7view0
If you already parse `ytInitialPlayerResponse` for metadata, extend that code to also read `captionTracks`. Your own notes say you already parse it for metadata; this change is a small increment. fileciteturn0file0
### Language selection heuristics that work in practice
Empirically (and in multiple reference scripts/libraries):
* `kind === "asr"` indicates **autogenerated** captions. citeturn1view1turn12search20turn4view1
* `vssId` often encodes track type and language; examples show leading `.` for “regular” and `a.` for autogenerated (`a.en`). citeturn4view0turn4view1
* If the desired language isnt available, you can request **machine translation** via `tlang=<targetLanguage>` on the timedtext URL (this is used by practical scripts; treat the result as machine translation quality). citeturn4view1turn9search1
A pragmatic heuristic for a notetaking plugin:
1. Prefer **manual** captions in requested language (exact match on BCP47, then base language match).
2. Else prefer **manual captions in any language** then use `tlang` to translate to requested base language (e.g., `en` for `en-GB`). citeturn4view1turn9search1
3. Else prefer **auto** captions in requested language. citeturn1view1turn4view1
4. Else pick the “best available” track (manual first, then auto) and optionally translate. citeturn4view1turn1view1
### Query parameters that matter
**`fmt`**: You should request `fmt=json3` if you want structured segment timing and easy parsing. citeturn3search3turn4view1turn4view0
However, multiple sources warn that you must **replace** an existing `fmt=srv3` rather than append another `&fmt=...` because duplicates may cause the first `fmt` to win. citeturn2search1turn4view0turn1view1
**`tlang`**: Add `tlang=<lang>` only when you are intentionally requesting a translation. citeturn4view1turn9search1
**Alternative formats**: timedtext can return WebVTT (useful for compatibility) via `fmt=vtt`, and other formats exist in tooling outputs. citeturn0search1turn2search4turn12search14
### Signature/cipher handling for caption URLs
In most implementations that read `captionTracks`, the captions URL is provided as a working `baseUrl` you can request directly. citeturn1view2turn4view1turn7view0
If you encounter a track that provides a cipher string (rare for captions, common for streaming formats), then:
* If the cipher contains an alreadydeciphered `sig`/`signature`, you can reconstruct the URL by appending it.
* If it contains an encrypted `s=` signature, full deciphering requires parsing YouTube player JS (high complexity, brittle; generally outside a “lightweight Obsidian plugin” scope). Evidence from downloader tooling shows signature extraction is a moving target and can fail when preconditions change. citeturn11search8turn2search10turn0search10
The plan below implements “best effort” support for cipher strings that already include `sig`, and provides clear error surfaces when deciphering would be required.
### Rate limiting and fail-fast policy
Given the repeated evidence of throttling and precondition failures, your plugin should:
* Retry only **transient** failures (timeouts, selected 5xx, and 429 with `Retry-After`). citeturn2search2turn11search12turn10view0
* Fail fast (no retries) on deterministic failures like `FAILED_PRECONDITION` from internal endpoints. citeturn11search8turn11search0turn0file0
## Step-by-step implementation plan and drop-in TypeScript patch
### Implementation plan
**Step one: implement a first-class “watch-page captions” method**
1. `fetchWatchHtml(videoId)` to GET `/watch?v=...` with a stable browser UA and `Accept-Language: en-GB`. citeturn4view1turn7view0
2. If the HTML indicates consent interstitial or missing player JSON, retry once with a `CONSENT=YES+1` cookie (best-effort). citeturn8search1
3. `extractInitialPlayerResponse(html)` using brace-balanced extraction. (This replaces brittle split tricks.) citeturn7view0turn4view1
4. Read `captionTracks` and select with `pickTrack(tracks, requestedLang)`. citeturn1view2turn4view1turn0search22
5. Build a final caption URL:
* replace existing `fmt` with `json3` (dont append duplicates). citeturn2search1turn4view0
* add `tlang` only if you decided to translate. citeturn4view1turn9search1
6. `fetchCaptionTrack(url)` and parse `json3` events into segments. citeturn3search3turn12search5
**Step two: add fallbacks**
1. Timedtext list fallback: call `https://www.youtube.com/api/timedtext?type=list&v=VIDEO_ID` to enumerate tracks if the watch page didnt expose `captionTracks`. citeturn12search1
2. `youtubei` fallback behind an explicit **experimental** toggle (default off), fail fast on `FAILED_PRECONDITION`, as your own notes recommend. fileciteturn0file0
3. Third-party Supadata fallback when configured (unchanged conceptually; just make messaging accurate). fileciteturn0file0
**Step three: improve UX/error reporting**
Record which methods were attempted and only mention those in user-visible output (your notes call out current inaccurate messaging). fileciteturn0file0
### Mermaid decision flow for `fetchTranscript`
```mermaid
flowchart TD
A[fetchTranscript(videoId, lang)] --> B[GET /watch HTML]
B -->|consent/page incomplete| B2[Retry with CONSENT cookie]
B --> C[Extract ytInitialPlayerResponse]
C -->|captionTracks present| D[Pick best caption track]
C -->|no captionTracks| E[Timedtext type=list fallback]
D --> F[Build URL: fmt=json3 + optional tlang]
F --> G[GET timedtext JSON3]
G --> H[Parse events -> TranscriptSegment[]]
E -->|track found| F
E -->|none| I[Optional: experimental youtubei fallback]
I -->|success| H
I -->|FAILED_PRECONDITION/blocked| J[Optional: Supadata fallback]
J -->|success| H
J -->|fail| K[Surface clear error + attempted methods]
```
### Drop-in TypeScript patch
This patch assumes a typical Obsidian plugin pattern:
* Your network layer is `obsidianFetch(...)` wrapping `requestUrl`. fileciteturn0file0
* You already have (or can add) a `TranscriptSegment` type and a class/namespace containing `fetchTranscript()`.
You will likely need to adjust imports and return types to match your existing code, but the functions below are intentionally “dropin”: `fetchWatchHtml`, `extractInitialPlayerResponse`, `pickTrack`, and `fetchCaptionTrack` are explicitly provided.
```ts
// src/youtube-transcript.ts
// Drop-in patch: prefer watch-page captionTracks -> timedtext json3, with robust parsing and fallbacks.
//
// Notes:
// - Do not append fmt=json3 if fmt already exists; replace it.
// - tlang is optional; use only when you want machine translation.
// - youtubei endpoints should be behind an explicit experimental toggle.
type TranscriptSegment = {
text: string;
start: number; // seconds
duration: number; // seconds
};
type CaptionTrack = {
baseUrl?: string;
languageCode?: string; // e.g. "en", "en-GB"
vssId?: string; // e.g. ".en", "a.en"
kind?: string; // "asr" means auto-generated
isTranslatable?: boolean;
name?: { simpleText?: string; runs?: Array<{ text?: string }> };
// Some implementations may also encounter cipher-style fields.
signatureCipher?: string;
};
type PlayerResponse = {
playabilityStatus?: { status?: string; reason?: string };
captions?: {
playerCaptionsTracklistRenderer?: {
captionTracks?: CaptionTrack[];
translationLanguages?: Array<{ languageCode?: string; languageName?: any }>;
};
};
};
type FetchTranscriptOptions = {
lang?: string; // preferred language, e.g. "en-GB"
enableExperimentalYoutubei?: boolean; // default false
enableTimedtextListFallback?: boolean; // default true
enableSupadataFallback?: boolean; // existing behaviour
debug?: boolean;
};
class YoutubeTranscriptError extends Error {
public readonly code:
| "NO_CAPTIONS"
| "UNPLAYABLE"
| "CONSENT_REQUIRED"
| "RATE_LIMITED"
| "NETWORK"
| "PARSE"
| "EXPERIMENTAL_BLOCKED"
| "UNKNOWN";
public readonly attempts: string[];
constructor(code: YoutubeTranscriptError["code"], message: string, attempts: string[] = []) {
super(message);
this.code = code;
this.attempts = attempts;
}
}
// You already have this in your codebase (per your notes).
// Adjust signature to your fetch shim.
async function obsidianFetchText(url: string, opts: { headers?: Record<string, string>; timeoutMs?: number } = {}) {
// Replace with your actual shim (requestUrl wrapper).
// Must return: { status: number, headers: Record<string,string>, text: string }
throw new Error("obsidianFetchText not wired");
}
async function obsidianFetchJson(url: string, opts: { headers?: Record<string, string>; timeoutMs?: number } = {}) {
// Replace with your actual shim.
throw new Error("obsidianFetchJson not wired");
}
export class YouTubeTranscriptExtractor {
private static readonly WATCH_BASE = "https://www.youtube.com/watch?v=";
private static readonly TIMEDTEXT_LIST = "https://www.youtube.com/api/timedtext?type=list&v=";
// A stable browser UA is used by many extractor fixes.
private static readonly USER_AGENT =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0 Safari/537.36";
public async fetchTranscript(videoIdOrUrl: string, opts: FetchTranscriptOptions = {}): Promise<TranscriptSegment[]> {
const attempts: string[] = [];
const requestedLang = (opts.lang ?? "en-GB").trim();
const videoId = this.extractVideoId(videoIdOrUrl);
// 1) Prefer watch-page captions (local directed)
try {
attempts.push("watch-page");
const html = await fetchWatchHtml(videoId, { lang: requestedLang, debug: !!opts.debug });
const player = extractInitialPlayerResponse(html);
const playStatus = player?.playabilityStatus?.status;
if (playStatus && playStatus !== "OK") {
// UNPLAYABLE, LOGIN_REQUIRED, AGE_RESTRICTED, etc.
throw new YoutubeTranscriptError(
"UNPLAYABLE",
`Video is not playable without additional access (playabilityStatus=${playStatus}).`,
attempts
);
}
const tracks = player?.captions?.playerCaptionsTracklistRenderer?.captionTracks ?? [];
if (!tracks.length) {
throw new YoutubeTranscriptError("NO_CAPTIONS", "No captionTracks found in ytInitialPlayerResponse.", attempts);
}
const trackPick = pickTrack(tracks, requestedLang);
const finalUrl = buildCaptionUrl(trackPick, requestedLang);
attempts.push("timedtext-json3");
const segments = await fetchCaptionTrack(finalUrl, { debug: !!opts.debug });
if (!segments.length) {
throw new YoutubeTranscriptError("NO_CAPTIONS", "Caption download succeeded but produced 0 segments.", attempts);
}
return segments;
} catch (err) {
if (opts.debug) console.warn("watch-page method failed:", err);
// continue fallbacks
}
// 2) Timedtext list fallback (public-ish)
if (opts.enableTimedtextListFallback !== false) {
try {
attempts.push("timedtext-list");
const tracks = await fetchTimedtextTrackList(videoId);
if (tracks.length) {
const trackPick = pickTrack(tracks, requestedLang);
const finalUrl = buildCaptionUrl(trackPick, requestedLang);
attempts.push("timedtext-json3");
const segments = await fetchCaptionTrack(finalUrl, { debug: !!opts.debug });
if (segments.length) return segments;
}
} catch (err) {
if (opts.debug) console.warn("timedtext-list fallback failed:", err);
}
}
// 3) Experimental youtubei fallback (default off)
if (opts.enableExperimentalYoutubei) {
try {
attempts.push("youtubei-experimental");
// Wire to your existing youtubei implementation, but fail-fast on FAILED_PRECONDITION.
// const segments = await this.fetchViaYoutubei(videoId, requestedLang);
// if (segments.length) return segments;
} catch (err: any) {
// If it's FAILED_PRECONDITION, do NOT retry.
throw new YoutubeTranscriptError(
"EXPERIMENTAL_BLOCKED",
"YouTube blocked internal API (FAILED_PRECONDITION). Try watch-page mode or Supadata.",
attempts
);
}
}
// 4) Supadata fallback (if configured) keep existing behaviour
// attempts.push("supadata");
// if (opts.enableSupadataFallback) return await this.fetchViaSupadata(...)
throw new YoutubeTranscriptError(
"NO_CAPTIONS",
`Could not obtain captions. Attempted: ${attempts.join(", ")}.`,
attempts
);
}
private extractVideoId(input: string): string {
// Keep your existing implementation if you already have one.
// This is intentionally permissive and mirrors common patterns.
const trimmed = input.trim();
if (/^[a-zA-Z0-9_-]{11}$/.test(trimmed)) return trimmed;
// Try URL parsing first
try {
const u = new URL(trimmed);
if (u.hostname === "youtu.be") {
const id = u.pathname.replace("/", "");
if (/^[a-zA-Z0-9_-]{11}$/.test(id)) return id;
}
const v = u.searchParams.get("v");
if (v && /^[a-zA-Z0-9_-]{11}$/.test(v)) return v;
// /shorts/ID or /embed/ID
const m = u.pathname.match(/\/(shorts|embed)\/([a-zA-Z0-9_-]{11})/);
if (m?.[2]) return m[2];
} catch {
// fall through
}
// Regex fallback
const m = trimmed.match(/(?:v=|\/)([a-zA-Z0-9_-]{11})(?:\b|$)/);
if (m?.[1]) return m[1];
throw new YoutubeTranscriptError("PARSE", "Unable to extract YouTube video ID from input.", []);
}
}
// --- watch page fetch + consent handling ---
async function fetchWatchHtml(
videoId: string,
opts: { lang: string; debug: boolean }
): Promise<string> {
const url = `${YouTubeTranscriptExtractor.WATCH_BASE}${videoId}&hl=${encodeURIComponent(opts.lang)}`;
const headersBase: Record<string, string> = {
"User-Agent": YouTubeTranscriptExtractor.USER_AGENT,
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en-GB,en;q=0.9",
};
// First attempt without consent cookie
const res1 = await obsidianFetchText(url, { headers: headersBase, timeoutMs: 15000 });
if (res1.status === 429) {
throw new YoutubeTranscriptError("RATE_LIMITED", "YouTube returned 429 while fetching watch HTML.", ["watch-page"]);
}
if (looksLikeConsentPage(res1.text)) {
// Best-effort retry with a minimal CONSENT cookie.
// NOTE: This may not work in all regions or in the future.
const headers2 = { ...headersBase, "Cookie": "CONSENT=YES+1" };
const res2 = await obsidianFetchText(url, { headers: headers2, timeoutMs: 15000 });
if (looksLikeConsentPage(res2.text)) {
throw new YoutubeTranscriptError("CONSENT_REQUIRED", "YouTube consent page blocked transcript extraction.", ["watch-page"]);
}
return res2.text;
}
return res1.text;
}
function looksLikeConsentPage(html: string): boolean {
// Heuristics: consent host or typical consent interstitial markers.
const h = html.toLowerCase();
return h.includes("consent.youtube.com") || h.includes("before you continue") || h.includes("consent.google.com");
}
// --- ytInitialPlayerResponse extraction (brace-balanced) ---
function extractInitialPlayerResponse(html: string): PlayerResponse {
// Try a few marker variants seen in the wild.
const markers = [
"var ytInitialPlayerResponse =",
"ytInitialPlayerResponse =",
];
for (const m of markers) {
const idx = html.indexOf(m);
if (idx === -1) continue;
const startBrace = html.indexOf("{", idx);
if (startBrace === -1) continue;
const jsonText = extractBalancedJsonObject(html, startBrace);
try {
return JSON.parse(jsonText) as PlayerResponse;
} catch (e: any) {
throw new YoutubeTranscriptError("PARSE", `Failed to parse ytInitialPlayerResponse JSON (${e?.message ?? "unknown"}).`, ["watch-page"]);
}
}
throw new YoutubeTranscriptError("PARSE", "ytInitialPlayerResponse not found in watch HTML.", ["watch-page"]);
}
function extractBalancedJsonObject(text: string, startIndex: number): string {
// Brace counting with string/escape awareness.
let depth = 0;
let inString = false;
let escape = false;
for (let i = startIndex; i < text.length; i++) {
const ch = text[i];
if (inString) {
if (escape) {
escape = false;
} else if (ch === "\\") {
escape = true;
} else if (ch === "\"") {
inString = false;
}
continue;
}
if (ch === "\"") {
inString = true;
continue;
}
if (ch === "{") {
depth++;
} else if (ch === "}") {
depth--;
if (depth === 0) {
return text.slice(startIndex, i + 1);
}
}
}
throw new YoutubeTranscriptError("PARSE", "Unterminated JSON object while extracting ytInitialPlayerResponse.", ["watch-page"]);
}
// --- caption track selection ---
function pickTrack(tracks: CaptionTrack[], requestedLang: string): CaptionTrack {
const normReq = normaliseLangTag(requestedLang);
const reqBase = normReq.split("-")[0];
const scored = tracks.map((t) => {
const lang = normaliseLangTag(t.languageCode ?? "");
const vss = (t.vssId ?? "").toLowerCase();
const isAuto = (t.kind ?? "").toLowerCase() === "asr" || vss.startsWith("a.");
const isManual = !isAuto;
let score = 0;
// Exact language match
if (lang === normReq) score += 100;
// Base language match (en matches en-GB, etc.)
if (lang && reqBase && lang.split("-")[0] === reqBase) score += 60;
// Manual preferred
if (isManual) score += 30;
else score += 10;
// vssId hints: ".en" (manual) vs "a.en" (auto)
if (vss === `.${reqBase}`) score += 20;
if (vss === `a.${reqBase}`) score += 10;
// Prefer translatable tracks if we need fallback translation later
if (t.isTranslatable) score += 5;
return { t, score };
});
scored.sort((a, b) => b.score - a.score);
return scored[0]?.t ?? tracks[0];
}
function normaliseLangTag(tag: string): string {
// Normalise "en_GB" -> "en-gb" and trim.
return tag.trim().replace("_", "-").toLowerCase();
}
// --- caption URL construction (fmt/tlang + cipher handling) ---
function buildCaptionUrl(track: CaptionTrack, requestedLang: string): string {
let baseUrl = track.baseUrl ?? "";
// Best-effort: handle cipher-style URLs if baseUrl is missing.
if (!baseUrl && track.signatureCipher) {
baseUrl = tryBuildUrlFromSignatureCipher(track.signatureCipher);
}
if (!baseUrl) {
throw new YoutubeTranscriptError("PARSE", "Caption track did not contain a usable baseUrl.", ["watch-page"]);
}
const u = new URL(baseUrl);
// fmt must be replaced, not duplicated.
u.searchParams.delete("fmt");
u.searchParams.set("fmt", "json3");
// Translation: only add tlang if we don't have the requested language directly.
const trackLang = normaliseLangTag(track.languageCode ?? "");
const reqBase = normaliseLangTag(requestedLang).split("-")[0];
const trackBase = trackLang.split("-")[0];
if (reqBase && trackBase && reqBase !== trackBase) {
// Use base language for tlang to maximise acceptance (pragmatic).
u.searchParams.set("tlang", reqBase);
} else {
u.searchParams.delete("tlang");
}
return u.toString();
}
function tryBuildUrlFromSignatureCipher(signatureCipher: string): string {
// signatureCipher is typically a querystring: "url=...&sp=sig&sig=..."
const p = new URLSearchParams(signatureCipher);
const url = p.get("url");
if (!url) return "";
const sp = p.get("sp") ?? "signature";
const sig = p.get("sig") ?? p.get("signature");
if (sig) {
const u = new URL(url);
u.searchParams.set(sp, sig);
return u.toString();
}
// Encrypted 's' would require deciphering player JS. We fail with a clear message.
if (p.get("s")) {
throw new YoutubeTranscriptError(
"PARSE",
"Caption URL requires signature deciphering (signatureCipher.s present). Not supported in local mode.",
["watch-page"]
);
}
return url;
}
// --- timedtext json3 fetch + parse ---
async function fetchCaptionTrack(
url: string,
opts: { debug: boolean }
): Promise<TranscriptSegment[]> {
const headers: Record<string, string> = {
"User-Agent": YouTubeTranscriptExtractor.USER_AGENT,
"Accept-Language": "en-GB,en;q=0.9",
};
const json = await obsidianFetchJson(url, { headers, timeoutMs: 15000 });
// Adjust according to your shim: some return {status, json}, some return json only.
const data = (json as any)?.json ?? json;
if (!data || data.wireMagic !== "pb3") {
// Some cases return XML if fmt wasn't applied correctly.
throw new YoutubeTranscriptError("PARSE", "Unexpected caption payload (expected json3/pb3).", ["timedtext-json3"]);
}
const events = Array.isArray(data.events) ? data.events : [];
const segments: TranscriptSegment[] = [];
for (const ev of events) {
if (!ev || !Array.isArray(ev.segs) || typeof ev.tStartMs !== "number") continue;
const textRaw = ev.segs.map((s: any) => (typeof s?.utf8 === "string" ? s.utf8 : "")).join("");
const text = cleanCaptionText(decodeHtmlEntities(textRaw));
if (!text) continue;
const start = ev.tStartMs / 1000;
const duration = typeof ev.dDurationMs === "number" ? ev.dDurationMs / 1000 : 0;
segments.push({ text, start, duration });
}
return segments;
}
function decodeHtmlEntities(input: string): string {
// Lightweight decoder (no DOM dependency).
// Expand as needed.
return input
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, "\"")
.replace(/&#39;/g, "'")
.replace(/&#x27;/g, "'");
}
function cleanCaptionText(s: string): string {
// Keep this conservative; callers can do heavier post-processing.
return s.replace(/\s+/g, " ").trim();
}
// --- timedtext type=list fallback (XML) ---
async function fetchTimedtextTrackList(videoId: string): Promise<CaptionTrack[]> {
const url = `${YouTubeTranscriptExtractor.TIMEDTEXT_LIST}${videoId}`;
const res = await obsidianFetchText(url, {
headers: {
"User-Agent": YouTubeTranscriptExtractor.USER_AGENT,
"Accept-Language": "en-GB,en;q=0.9",
},
timeoutMs: 15000,
});
if (res.status === 429) {
throw new YoutubeTranscriptError("RATE_LIMITED", "YouTube returned 429 while fetching timedtext track list.", ["timedtext-list"]);
}
if (res.status >= 400) {
throw new YoutubeTranscriptError("NETWORK", `Timedtext tracklist request failed (HTTP ${res.status}).`, ["timedtext-list"]);
}
// Parse XML without DOM libs: a minimal regex parse for <track ... /> elements.
const tracks: CaptionTrack[] = [];
const re = /<track\b([^>]*)\/>/g;
let m: RegExpExecArray | null;
while ((m = re.exec(res.text)) !== null) {
const attrs = m[1];
const langCode = matchXmlAttr(attrs, "lang_code");
const name = matchXmlAttr(attrs, "name");
const kind = matchXmlAttr(attrs, "kind"); // "asr" appears here for auto captions in many cases
// Build a timedtext URL similar to what baseUrl would point to.
// NOTE: name may need URL encoding.
if (!langCode) continue;
const timedtextUrl = new URL("https://www.youtube.com/api/timedtext");
timedtextUrl.searchParams.set("v", videoId);
timedtextUrl.searchParams.set("lang", langCode);
if (name) timedtextUrl.searchParams.set("name", name);
if (kind) timedtextUrl.searchParams.set("kind", kind);
tracks.push({
baseUrl: timedtextUrl.toString(),
languageCode: langCode,
kind,
// crude vssId approximation (matches common conventions)
vssId: (kind === "asr" ? `a.${langCode}` : `.${langCode}`),
isTranslatable: true,
});
}
return tracks;
}
function matchXmlAttr(attrs: string, name: string): string | undefined {
const re = new RegExp(`${name}="([^"]*)"`);
const m = attrs.match(re);
return m?.[1];
}
```
Key implementation choices in this patch are grounded in observed behaviour and reference implementations:
* `ytInitialPlayerResponse` is extracted from a script within the watch HTML and used to locate `captionTracks[].baseUrl`. citeturn7view0turn4view1turn1view2
* Auto-generated tracks are identified via `kind: "asr"` and/or `vssId` conventions such as `a.en`. citeturn1view1turn4view0turn4view1
* `fmt=json3` is used to get `wireMagic: "pb3"` + `events` and parse segments. citeturn3search3turn12search5turn4view0
* Existing `fmt` is replaced rather than appended because duplicates can lead to XML output and parsing failures. citeturn2search1turn4view0
* `type=list` timedtext fallback is a known endpoint used to enumerate caption tracks. citeturn12search1turn12search13
### Example HTTP requests/responses you can use while debugging
**Fetch watch HTML and confirm `ytInitialPlayerResponse` exists:**
```bash
curl -L \
-H "User-Agent: Mozilla/5.0" \
-H "Accept-Language: en-GB,en;q=0.9" \
"https://www.youtube.com/watch?v=VIDEO_ID&hl=en-GB"
```
The “watch-page captions” approach consists of parsing that HTML for `ytInitialPlayerResponse` and reading `captionTracks[].baseUrl`. citeturn1view2turn7view0turn4view1
**Fetch captions in JSON3 (preferred):**
```bash
curl -L \
-H "User-Agent: Mozilla/5.0" \
"https://www.youtube.com/api/timedtext?v=VIDEO_ID&lang=en&fmt=json3"
```
JSON3s `wireMagic: "pb3"` and `events` structure is documented by real examples and consumer code. citeturn3search3turn12search5turn4view0
**Fetch captions as WebVTT (optional alternative):**
```bash
curl -L \
"https://www.youtube.com/api/timedtext?v=VIDEO_ID&lang=en&fmt=vtt"
```
Appending `fmt=vtt` to a caption URL (or timedtext URL) is a known way to retrieve WebVTT captions. citeturn0search1turn12search14
**List tracks (fallback):**
```bash
curl -L "https://www.youtube.com/api/timedtext?type=list&v=VIDEO_ID"
```
The `type=list` endpoint is commonly referenced as a way to fetch caption track metadata. citeturn12search1
## Suggested tests and edge cases
The goal is to test your **parser invariants** (HTML extraction, URL rewriting, JSON3 parsing) without relying on live YouTube calls in CI.
### Unit tests
**Balanced JSON extraction**
* Input: a fixture HTML snippet containing `var ytInitialPlayerResponse = { ... };` with nested braces and string escapes.
* Assert: `extractInitialPlayerResponse()` returns an object, and no truncation occurs when braces appear inside strings. This hardens you against brittle split hacks seen in quick fixes. citeturn4view1turn7view0
**Track picking**
* Provide fixture `captionTracks` with:
* manual `en`, manual `fr`, auto `en` (`kind: "asr"`), and `en-GB` variants
* Assert: `pickTrack(..., "en-GB")` chooses manual `en-GB` if present, otherwise manual `en`, otherwise auto `en`. Conventions used by reference scripts indicate `.en` vs `a.en` distinctions. citeturn4view1turn4view0turn1view1
**URL rewriting**
* Input URLs:
* `...&fmt=srv3&...`
* `...&fmt=srv3&fmt=vtt&...`
* Assert: result contains exactly one `fmt=json3`. This guards the known duplicate`fmt` pitfall. citeturn2search1turn4view0
**JSON3 parsing**
* Fixture JSON with `wireMagic: "pb3"`, `events` including:
* normal segments
* events with `segs: [{"utf8":"\n"}]`
* events with no `segs`
* Assert: you skip empties and compute `start/duration` from ms fields. Real JSON3 examples show the timing fields and `segs[].utf8` pattern. citeturn3search3turn12search5
**Timedtext list XML parsing**
* Fixture XML like:
* `<track lang_code="en" name="English" />`
* `<track lang_code="en" kind="asr" />`
* Assert: you produce `CaptionTrack[]` and set `kind` appropriately. The existence of `type=list` and `kind=asr` usage is documented in references. citeturn12search1turn12search6turn12search20
### Integration tests (recommended as opt-in)
Live internet tests are inherently flaky due to throttling, locale consent gates, and page changes. Evidence from tools shows rate limiting and intermittent failures are normal. citeturn2search2turn10view0
If you still want integration tests:
* Gate them behind an environment variable (e.g., `RUN_YOUTUBE_LIVE_TESTS=1`).
* Use a small set of known public video IDs that you periodically refresh manually.
* Assert only broad invariants: “segments length > 0” and “first segment start is a number”.
### Edge cases to explicitly support or surface as clear errors
* **No captions**: `captionTracks` missing or empty → user-facing “No captions available for this video.” (common). fileciteturn0file0
* **Unplayable / restricted**: `playabilityStatus.status != "OK"` (e.g., login/age restricted) → explain that local extraction cant proceed without authentication. fileciteturn0file0
* **Consent interstitial**: if watch HTML is a consent page, retry with consent cookie; if still blocked, show a consent-specific error. Using a `CONSENT=YES+1` cookie is a known workaround in scraping contexts. citeturn8search1
* **429 throttling**: surface “rate limited” and advise the user to retry later; do not spin retries. Rate limiting is repeatedly observed in tooling. citeturn2search2turn10view0
* **fmt duplication**: ensure replacing `fmt` is mandatory; otherwise you can get XML and break JSON parsing. citeturn2search1turn4view0
* **Machine translation**: if you use `tlang`, consider appending a short note in the UI/metadata that this transcript is machine-translated. Scripts and official APIs both treat `tlang` as machine translation. citeturn9search1turn4view1
## Migration checklist and method comparison
### Migration checklist
1. Implement the watchpage `ytInitialPlayerResponse → captionTracks[].baseUrl` path as the default “local directed” route. fileciteturn0file0turn1view2
2. Replace any `youtubei`-derived caption retrieval as the default; keep it only as an explicit experimental toggle and fail fast on `FAILED_PRECONDITION`. fileciteturn0file0turn11search8
3. Ensure `fmt` is replaced (not appended) when requesting `json3`. citeturn2search1turn4view0
4. Add timedtext `type=list` fallback to handle cases where watch HTML does not expose caption tracks. citeturn12search1
5. Fix user messaging to only list fallbacks that actually ran (your notes call out the current misleading Supadata claim). fileciteturn0file0
6. Add unit tests for: player response extraction, URL rewriting, JSON3 parsing, and track selection. citeturn3search3turn4view1
### Comparison table of methods
The “reliability” column below is a practical engineering judgement based on the evidence that (a) internal endpoints are frequently blocked by changing integrity requirements, while (b) timedtext URLs and watch-page parsing are common in working implementations; it is not an official guarantee. citeturn11search0turn11search8turn1view2turn4view1turn12search1
| Method | Reliability | Complexity | Auth/headers needed | When to use |
|---|---|---|---|---|
| Watch-page parsing (`/watch` → `ytInitialPlayerResponse``captionTracks[].baseUrl`) | High (best local default) | Medium (HTML parsing + JSON extraction) | Browser UA; may need consent cookie in some locales citeturn8search1turn7view0 | Default “local directed” transcript acquisition citeturn1view2turn4view1 |
| Timedtext list + download (`/api/timedtext?type=list` → fetch chosen track) | MediumHigh (good fallback) | Medium (XML parsing + URL construction) | Usually none beyond UA; can still be blocked/rate-limited citeturn12search1turn2search2 | Fallback when watch HTML lacks captionTracks citeturn12search1 |
| Private internal API (`youtubei/v1/player`, `youtubei/v1/get_transcript`) | Low (brittle) | High (client context/version/integrity) | Special headers; can fail with `FAILED_PRECONDITION` citeturn11search8turn0file0 | Experimental toggle only; diagnostics; last resort fileciteturn0file0 |
| Supadata (third-party) | High if service is up | Low in code, higher in dependencies | API key + network to third-party | Reliable fallback for users willing to use a service fileciteturn0file0 |
### Note on official APIs
The official YouTube Data API *can* list and download caption tracks, but `captions.download` requires OAuth and permission to edit the video, so it is not a general public transcript solution for arbitrary videos. citeturn9search1turn9search11turn9search2

10
docs/index.html Normal file
View file

@ -0,0 +1,10 @@
<!DOCTYPE html>
<html>
<head>
<title>TubeSage Documentation</title>
<meta http-equiv="refresh" content="0; url=video.html">
</head>
<body>
<p>Redirecting to video demonstration...</p>
</body>
</html>

View file

@ -0,0 +1,616 @@
# Comprehensive Obsidian Plugin Development Guidelines
*Retrieved from Obsidian Developer Documentation via Context7*
## Core Development Principles
### 1. App Instance Management
**NEVER use the global app object:**
```typescript
// ❌ Bad - Avoid global app
const globalApp = app;
// ✅ Good - Use plugin instance
class MyPlugin extends Plugin {
onload() {
this.app.vault.read("path/to/file.md");
}
}
```
### 2. Resource Management
**Always clean up resources on unload:**
```typescript
export default class MyPlugin extends Plugin {
onload() {
// Use registerEvent for automatic cleanup
this.registerEvent(this.app.vault.on('create', this.onCreate));
}
onCreate: (file: TAbstractFile) => {
// Handle file creation
}
}
```
### 3. Security Best Practices
**Never use innerHTML for user input:**
```typescript
// ❌ Vulnerable to XSS
function showNameUnsafe(name: string) {
let containerElement = document.querySelector('.my-container');
containerElement.innerHTML = `<div class="my-class"><b>Your name is: </b>${name}</div>`;
}
// ✅ Safe DOM construction
function showNameSecure(name: string) {
let containerElement = document.querySelector('.my-container');
if (containerElement) {
containerElement.empty();
const div = containerElement.createDiv({ cls: "my-class" });
div.createEl("b", { text: "Your name is: " });
div.createSpan({ text: name });
}
}
```
## File System Operations
### Prefer Vault API Over Adapter API
```typescript
// ✅ Good - Use Vault API
this.app.vault.create('path/to/new-file.md', 'content');
this.app.vault.read(file);
// ❌ Bad - Direct Adapter API
this.app.vault.adapter.write('path/to/file', 'content');
```
### Optimize File Access
```typescript
// ❌ Inefficient - Don't iterate all files
this.app.vault.getFiles().find(file => file.path === filePath);
// ✅ Efficient - Direct access
const file = this.app.vault.getFileByPath(filePath);
const folder = this.app.vault.getFolderByPath(folderPath);
const abstractFile = this.app.vault.getAbstractFileByPath(filePath);
// Type checking
if (file instanceof TFile) {
// it's a file
}
if (file instanceof TFolder) {
// it's a folder
}
```
### Handle Frontmatter Properly
```typescript
// ✅ Use FileManager.processFrontMatter
// Atomic operation, avoids conflicts with other plugins
await this.app.fileManager.processFrontMatter(file, (frontmatter) => {
frontmatter.title = 'New Title';
});
```
### File Editing Best Practices
```typescript
// ✅ For active files - Use Editor API (preserves cursor position)
const editor = this.app.workspace.activeEditor?.editor;
if (editor) {
editor.replaceRange('new text', { line: 0, ch: 0 });
}
// ✅ For background files - Use Vault.process (atomic)
await this.app.vault.process(file, (content) => {
return content.replace(/old/g, 'new');
});
```
## Network Operations
### Use Obsidian's requestUrl
```typescript
// ✅ Good - Cross-platform compatible
import { requestUrl } from 'obsidian';
async function fetchData(url: string) {
try {
const response = await requestUrl(url);
console.log(response.json);
} catch (error) {
console.error('Request failed:', error);
}
}
// ❌ Bad - Won't work on mobile
// fetch(url).then(...)
// axios.get(url).then(...)
```
## Path Handling
### Always Normalize User Paths
```typescript
import { normalizePath } from 'obsidian';
// ✅ Safe path handling
const pathToPlugin = normalizePath('//my-folder\\file');
// Result: "my-folder/file" not "//my-folder\"
```
## Async Operations
### Use async/await Over Promises
```typescript
// ❌ Hard to read Promise chains
function test(): Promise<string | null> {
return requestUrl('https://example.com')
.then(res => res.text)
.catch(e => {
console.log(e);
return null;
});
}
// ✅ Clean async/await
async function AsyncTest(): Promise<string | null> {
try {
let res = await requestUrl('https://example.com');
let text = await res.text;
return text;
} catch (e) {
console.log(e);
return null;
}
}
```
## Workspace and View Management
### Access Views Safely
```typescript
// ✅ Safe view access
const view = this.app.workspace.getActiveViewOfType(MarkdownView);
if (view) {
// Work with view
}
const editor = this.app.workspace.activeEditor?.editor;
if (editor) {
// Work with editor
}
```
### Handle Custom Views Properly
```typescript
// ❌ Bad - Storing view references
this.registerView(MY_VIEW_TYPE, () => this.view = new MyCustomView());
// ✅ Good - Factory function
this.registerView(MY_VIEW_TYPE, () => new MyCustomView());
// ✅ Access views when needed
for (let leaf of app.workspace.getActiveLeavesOfType(MY_VIEW_TYPE)) {
let view = leaf.view;
if (view instanceof MyCustomView) {
// Work with view
}
}
```
### Handle Deferred Views (Obsidian 1.7.2+)
```typescript
// ✅ Good - Safe instanceof check
workspace.iterateAllLeaves(leaf => {
if (leaf.view instanceof MyCustomView) {
// View is fully loaded
}
});
// ❌ Bad - Unsafe type assertion
workspace.iterateAllLeaves(leaf => {
if (leaf.view.getViewType() === 'my-view') {
let view = leaf.view as MyCustomView; // Dangerous!
}
});
```
## Plugin Data Management
### Use Plugin Data Methods
```typescript
// ✅ Good - Use built-in methods
export default class MyPlugin extends Plugin {
async onload() {
const myData = await this.loadData();
if (myData) {
console.log('Loaded data:', myData);
}
}
async saveMyData(data: any) {
await this.saveData(data);
}
}
// ❌ Bad - Manual file management
// fs.writeFileSync(this.manifest.dir + '/data.json', JSON.stringify(data));
```
## Settings and UI
### Use Proper Setting Creation
```typescript
// ✅ Good - Use setHeading() API
new Setting(containerEl).setName('Your Heading Title').setHeading();
// ❌ Bad - Direct HTML
// containerEl.innerHTML = '<h1>Your Heading Title</h1>';
```
### Command Registration Best Practices
```typescript
// ❌ Bad - Redundant plugin name
this.addCommand({
id: 'my-plugin-do-something',
name: 'My Plugin: Do Something',
callback: () => { /* ... */ }
});
// ✅ Good - Clean naming
this.addCommand({
id: 'do-something',
name: 'Do Something',
callback: () => { /* ... */ }
});
```
### Avoid Default Hotkeys
```typescript
// ❌ Bad - Setting default hotkeys causes conflicts
this.addCommand({
id: 'my-command',
name: 'My Command',
hotkeys: [{ modifiers: ['Ctrl'], key: 'k' }], // Don't do this
callback: () => {}
});
// ✅ Good - Let users set their own hotkeys
this.addCommand({
id: 'my-command',
name: 'My Command',
callback: () => {}
});
```
## Editor Extensions
### Update Extensions Dynamically
```typescript
class MyPlugin extends Plugin {
private editorExtension: Extension[] = [];
onload() {
this.registerEditorExtension(this.editorExtension);
}
updateEditorExtension() {
// Empty array while keeping same reference
this.editorExtension.length = 0;
// Add new extension
let myNewExtension = this.createEditorExtension();
this.editorExtension.push(myNewExtension);
// Flush changes to all editors
this.app.workspace.updateOptions();
}
}
```
## Platform Detection
### Use Obsidian's Platform API
```typescript
import { Platform } from 'obsidian';
// ✅ Good - Cross-platform compatible
if (Platform.isMobile) {
console.log('Running on mobile');
}
if (Platform.isIosApp) {
// iOS-specific code
}
if (Platform.isAndroidApp) {
// Android-specific code
}
// ❌ Bad - Node.js specific
// if (process.platform === 'darwin') {
// console.log('Running on macOS');
// }
```
## Performance Optimization
### Optimize Plugin Load Time
```typescript
// ✅ Defer non-critical setup
class MyPlugin extends Plugin {
onload() {
// Critical setup only
this.app.workspace.onLayoutReady(() => {
// Defer heavy operations
this.registerEvent(this.app.vault.on('create', this.onCreate));
});
}
onCreate() {
if (!this.app.workspace.layoutReady) {
return; // Skip during initial load
}
// Handle file creation
}
}
```
## Modern JavaScript Practices
### Use Modern Variable Declarations
```typescript
// ✅ Good - Modern JavaScript
let count = 0;
const MAX_COUNT = 10;
// ❌ Bad - Avoid var
// var oldVar = 'value';
```
### Avoid Global Variables
```typescript
// ✅ Good - Encapsulated scope
class MyClass {
private myVariable: string = 'local';
constructor() {
console.log(this.myVariable);
}
}
// ❌ Bad - Global variables
// let globalVar = 'global';
```
### Strong TypeScript Typing
```typescript
// ✅ Good - Proper typing
interface MyData { id: number; name: string; }
const data: MyData = { id: 1, name: 'Example' };
// ❌ Bad - Using 'as any'
// const data: any = { id: 1, name: 'Example' };
// const id = (data as any).id;
```
## Dependencies
### Import from Obsidian When Available
```typescript
// ✅ Good - Use Obsidian's bundled libraries
import { moment } from 'obsidian';
// ❌ Bad - Bundling your own copy
// import moment from 'moment';
```
## CSS Styling Guidelines
### Use CSS Classes, Not Inline Styles
```typescript
// ❌ Bad - Inline styles via JavaScript
const el = containerEl.createDiv();
el.style.color = 'white';
el.style.backgroundColor = 'red';
el.style.display = 'none'; // Avoid this pattern
// ✅ Good - CSS classes for styling and visibility
const el = containerEl.createDiv({cls: 'warning-container hidden'});
// ✅ Good - Dynamic class toggling instead of style assignments
if (shouldHide) {
el.addClass('hidden');
} else {
el.removeClass('hidden');
}
```
```css
/* styles.css */
.warning-container {
color: var(--text-normal);
background-color: var(--background-modifier-error);
}
```
### Scope Your CSS
```css
/* ❌ Bad - Overriding core styles */
.some-obsidian-core-class {
color: red;
}
/* ✅ Good - Scoped to plugin */
.my-plugin-container .some-element {
color: blue;
}
```
## Plugin Release Guidelines
### Required Files
Your repository must contain:
- `README.md` - Plugin description and usage
- `LICENSE` - Usage terms and conditions
- `manifest.json` - Plugin metadata
### Manifest.json Structure
```json
{
"id": "my-plugin",
"name": "My Plugin",
"version": "1.0.0",
"minAppVersion": "0.15.0",
"description": "A description of my plugin.",
"author": "Your Name",
"authorUrl": "https://yourwebsite.com",
"fundingUrl": "https://ko-fi.com/yourname",
"isDesktopOnly": false
}
```
### Version Management
```json
// manifest.json
{
"version": "1.0.0",
"minAppVersion": "1.2.0"
}
```
```json
// versions.json (optional fallbacks)
{
"0.1.0": "1.0.0",
"0.12.0": "1.1.0"
}
```
### GitHub Actions Release
```yaml
name: Release Obsidian plugin
on:
push:
tags:
- "*"
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v3
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: "18.x"
- name: Build plugin
run: |
npm install
npm run build
- name: Create release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
tag="${GITHUB_REF#refs/tags/}"
gh release create "$tag" \
--title="$tag" \
--draft \
main.js manifest.json styles.css
```
## Common Anti-Patterns to Avoid
### ❌ Things to Avoid
1. **Don't use global app object**
2. **Don't include 'Obsidian' in plugin name** (unless essential)
3. **Don't use innerHTML with user input** (XSS vulnerability)
4. **Don't set default hotkeys** (causes conflicts)
5. **Don't use deprecated methods** (check for strikethrough in IDE)
6. **Don't detach leaves in onunload** (breaks user layout)
7. **Don't manage view references** (causes memory leaks)
8. **Don't use Node.js APIs on mobile**
9. **Don't include main.js in repository** (only in releases)
10. **Don't leave console.log statements** (unless necessary)
11. **Don't use placeholder names** (MyPlugin, SampleSettingTab)
12. **Don't override core CSS classes**
13. **Don't use `!important` in CSS**
14. **Don't use `:has()` CSS selector** (performance issues)
15. **Don't manually manage plugin data files**
## Plugin Development Checklist
### Before Release
- [ ] Replace all placeholder names
- [ ] Remove unnecessary console.log statements
- [ ] Scan for deprecated methods
- [ ] Optimize plugin load time
- [ ] Test on mobile (if not desktop-only)
- [ ] Ensure CSS is scoped to plugin
- [ ] Use strong TypeScript typing
- [ ] Add funding URL to manifest
- [ ] Minimize main.js file
- [ ] Test with DeferredViews (Obsidian 1.7.2+)
- [ ] Use proper heading methods in settings
- [ ] Avoid setting default hotkeys
- [ ] Clean up resources on unload
- [ ] Use Vault API over Adapter API
- [ ] Handle paths with normalizePath()
- [ ] Use requestUrl for network requests
### Repository Structure
```
my-plugin/
├── README.md
├── LICENSE
├── manifest.json
├── main.ts
├── styles.css
├── package.json
├── tsconfig.json
├── esbuild.config.mjs
└── .gitignore (exclude main.js)
```
This comprehensive guide covers all essential aspects of Obsidian plugin development, from basic setup to advanced performance optimization and security considerations. Following these guidelines ensures your plugin is robust, secure, and compatible with Obsidian's ecosystem.

34
docs/video.html Normal file
View file

@ -0,0 +1,34 @@
<!DOCTYPE html>
<html>
<head>
<title>Video Player</title>
<style>
body {
margin: 0;
padding: 0;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background-color: #f0f0f0;
}
.video-container {
max-width: 800px;
width: 100%;
}
video {
width: 100%;
height: auto;
display: block;
}
</style>
</head>
<body>
<div class="video-container">
<video controls>
<source src="./TubeSage.mp4" type="video/mp4">
Your browser does not support the video tag.
</video>
</div>
</body>
</html>

266
docs/workflow-diagram.md Normal file
View file

@ -0,0 +1,266 @@
# Workflow Diagram - TubeSage
This diagram describes the complete user and system workflow for the TubeSage plugin for Obsidian, including the latest features and architecture improvements.
```mermaid
%%{init: {
'theme': 'dark',
'themeVariables': {
'fontSize': '14px',
'primaryColor': '#7aa2f7',
'primaryTextColor': '#ffffff',
'primaryBorderColor': '#7aa2f7',
'lineColor': '#7aa2f7',
'secondaryColor': '#bb9af7',
'tertiaryColor': '#ff9e64'
},
'flowchart': {
'curve': 'basis',
'useMaxWidth': true,
'htmlLabels': true,
'rankSpacing': 70,
'nodeSpacing': 50
}
}}%%
flowchart TD
%% Entry Points
Start([User Starts]) --> Setup{Plugin Setup Complete?}
Setup -->|No| Config[Configure Settings]
Config --> LicenseCheck[Accept License]
LicenseCheck --> APISetup[Setup API Keys]
APISetup --> Setup
Setup -->|Yes| UserAction{User Action}
%% User Actions
UserAction -->|Ribbon Click| RibbonModal[Open Main Modal]
UserAction -->|Command Palette| CommandModal[Extract YouTube Transcript]
UserAction -->|Direct URL| ProcessURL[Process URL Directly]
%% Modal Flow
RibbonModal --> URLInput[Enter YouTube URL]
CommandModal --> URLInput
URLInput --> URLValidation{Valid YouTube URL?}
URLValidation -->|No| ErrorMsg[Show Error Message]
URLValidation -->|Yes| URLType{URL Type Detection}
%% URL Type Processing
URLType -->|Single Video| VideoFlow[Single Video Processing]
URLType -->|Channel/Playlist| BatchFlow[Batch Processing]
%% Single Video Flow
VideoFlow --> PlatformDetect{Platform Detection}
PlatformDetect -->|Desktop| DesktopExtract[Desktop Transcript Extraction]
PlatformDetect -->|Mobile| MobileExtract[Mobile Optimized Extraction]
%% Transcript Extraction
DesktopExtract --> TranscriptCheck{Transcript Available?}
MobileExtract --> TranscriptCheck
TranscriptCheck -->|No| FallbackMethod[Try Alternative Methods]
FallbackMethod --> SmartRecovery[Smart Recovery System]
SmartRecovery --> TranscriptCheck
TranscriptCheck -->|Yes| MetadataExtract[Extract Video Metadata]
%% LLM Processing
MetadataExtract --> LLMChoice{Use LLM Summarization?}
LLMChoice -->|No| DirectNote[Create Note Directly]
LLMChoice -->|Yes| ModelSelection[Smart Model Selection]
ModelSelection --> ProviderSelect{LLM Provider}
%% LLM Provider Paths
ProviderSelect -->|OpenAI| OpenAIClient[OpenAI via LangChain]
ProviderSelect -->|Anthropic| AnthropicClient[Anthropic via LangChain]
ProviderSelect -->|Google| GeminiClient[Gemini via LangChain]
ProviderSelect -->|Ollama| OllamaClient[Direct Ollama API]
%% LLM Response Processing
OpenAIClient --> LLMResponse[Process LLM Response]
AnthropicClient --> LLMResponse
GeminiClient --> LLMResponse
OllamaClient --> LLMResponse
%% Timestamp Processing
LLMResponse --> TimestampCheck{Add Timestamps?}
TimestampCheck -->|No| TemplateApply[Apply Template]
TimestampCheck -->|Yes| ChunkContent[Create Optimized Chunks]
ChunkContent --> ProcessChunks[Process Each Chunk]
ProcessChunks --> AddTimestamps[Add Timestamp Links]
AddTimestamps --> ValidateLinks[Validate Generated Links]
ValidateLinks --> ReconstructDoc[Reconstruct Document]
ReconstructDoc --> TemplateApply
%% Final Note Creation
DirectNote --> TemplateApply
TemplateApply --> CreateNote[Create Obsidian Note]
CreateNote --> PerformanceLog[Log Performance Metrics]
PerformanceLog --> Success[Success - Note Created]
%% Batch Processing Flow
BatchFlow --> APIKeyCheck{YouTube API Key Set?}
APIKeyCheck -->|No| APIKeyError[Show API Key Required Error]
APIKeyCheck -->|Yes| FetchVideos[Fetch Channel/Playlist Videos]
FetchVideos --> BatchConfig[Configure Batch Settings]
BatchConfig --> ProcessingMode{Processing Mode}
ProcessingMode -->|Sequential| SequentialBatch[Process Videos in Sequence]
ProcessingMode -->|Parallel| ParallelBatch[Process Videos in Parallel]
SequentialBatch --> VideoLoop[For Each Video]
ParallelBatch --> VideoLoop
VideoLoop --> VideoFlow
%% Error Handling
ErrorMsg --> UserAction
APIKeyError --> Config
SmartRecovery --> ErrorAnalysis[Analyze Error Type]
ErrorAnalysis --> RetryStrategy[Determine Retry Strategy]
RetryStrategy --> MaxRetries{Max Retries Reached?}
MaxRetries -->|No| FallbackMethod
MaxRetries -->|Yes| FinalError[Show Final Error]
FinalError --> End([End])
%% Performance Monitoring
PerformanceLog --> BottleneckCheck[Check for Bottlenecks]
BottleneckCheck --> OptimizationSugg[Generate Optimization Suggestions]
OptimizationSugg --> End
Success --> End
%% Styling
style Start fill:#73daca,stroke:#73daca,color:#1a1b26
style End fill:#73daca,stroke:#73daca,color:#1a1b26
style Success fill:#9ece6a,stroke:#9ece6a,color:#1a1b26
style ErrorMsg fill:#f7768e,stroke:#f7768e,color:white
style APIKeyError fill:#f7768e,stroke:#f7768e,color:white
style FinalError fill:#f7768e,stroke:#f7768e,color:white
style LLMResponse fill:#bb9af7,stroke:#bb9af7,color:white
style CreateNote fill:#ff9e64,stroke:#ff9e64,color:white
style SmartRecovery fill:#e0af68,stroke:#e0af68,color:white
```
## Workflow Overview
The TubeSage workflow is designed for maximum flexibility and reliability across different platforms and use cases. The system intelligently adapts to user needs while providing robust error recovery and performance optimization.
### Key Workflow Features
#### 🚀 **Setup and Configuration**
- **License Validation**: Ensures user acceptance of MIT license before operation
- **API Key Management**: Secure configuration of multiple LLM provider credentials
- **Platform Detection**: Automatic detection and optimization for desktop vs mobile environments
- **Settings Persistence**: All configurations are saved and restored between sessions
#### 🎯 **User Interaction Modes**
- **Ribbon Interface**: Quick access via the YouTube icon in Obsidian's ribbon
- **Command Palette**: Integration with Obsidian's command system for keyboard-driven workflows
- **Direct URL Processing**: Seamless handling when users paste YouTube URLs directly
#### 🔍 **Intelligent URL Processing**
- **URL Validation**: Comprehensive validation ensuring only valid YouTube URLs are processed
- **Type Detection**: Automatic identification of single videos vs channels/playlists
- **Error Guidance**: Helpful error messages with specific solutions for common issues
#### 📱 **Cross-Platform Extraction**
- **Desktop Optimization**: Full-featured extraction using standard web APIs
- **Mobile Adaptation**: Specialized extraction methods optimized for iOS/Android limitations
- **Fallback Systems**: Multiple extraction methods ensure high success rates
- **Smart Recovery**: Intelligent retry mechanisms with parameter optimization
#### 🤖 **Advanced LLM Integration**
- **Smart Model Selection**: AI-driven recommendations based on content complexity and length
- **Unified Provider Interface**: Consistent experience across OpenAI, Anthropic, Google, and Ollama
- **LangChain Integration**: Standardized API interface for cloud providers
- **Local Processing**: Direct Ollama integration for privacy-focused users
#### ⏱️ **Intelligent Timestamp Processing**
- **Optimized Chunking**: Smart content division to respect LLM token limits
- **Heading Detection**: Automatic identification of section headings for timestamp placement
- **Link Generation**: Creation of clickable YouTube timestamp links
- **Validation System**: Comprehensive validation of generated links with retry mechanisms
#### 📊 **Performance Monitoring**
- **Real-time Metrics**: Continuous tracking of processing times across all components
- **Bottleneck Detection**: Automatic identification of performance issues
- **Optimization Suggestions**: AI-driven recommendations for performance improvements
- **Component-level Tracking**: Separate metrics for extraction, LLM processing, and timestamp generation
### Workflow Phases
#### **Phase 1: Initialization**
1. Plugin loads and checks for proper configuration
2. License acceptance validation
3. API key verification for selected providers
4. Platform detection and adaptation
5. Performance monitoring initialization
#### **Phase 2: User Input**
1. User selects input method (ribbon, command palette, or direct URL)
2. URL input and validation
3. URL type detection (single video vs batch processing)
4. Configuration selection (summary mode, folder, etc.)
#### **Phase 3: Content Extraction**
1. Platform-specific transcript extraction
2. Multiple fallback methods for reliability
3. Smart recovery system for failed extractions
4. Video metadata extraction (title, description, duration)
#### **Phase 4: AI Processing**
1. Smart model selection based on content analysis
2. LLM provider initialization via factory pattern
3. Content summarization with optimized prompts
4. Response validation and quality checks
#### **Phase 5: Enhancement**
1. Document component extraction (frontmatter, content)
2. Optimized content chunking for timestamp processing
3. Timestamp link generation and validation
4. Document reconstruction with enhanced content
#### **Phase 6: Finalization**
1. Template application via Templater integration
2. Note creation in specified Obsidian folder
3. Performance metrics logging
4. Optimization suggestions generation
### Batch Processing Workflow
#### **Collection Processing**
1. YouTube API integration for channel/playlist data
2. Video list extraction with quota management
3. Processing mode selection (sequential vs parallel)
4. Progress tracking and user feedback
#### **Parallel Processing Features**
- Configurable concurrency limits
- Rate limiting to respect API quotas
- Progress monitoring for multiple videos
- Error isolation (single video failures don't stop batch)
### Error Handling Strategy
#### **Smart Recovery System**
- **Error Classification**: Automatic categorization by type and severity
- **Retry Logic**: Intelligent retry strategies with exponential backoff
- **Parameter Adjustment**: Dynamic optimization between retry attempts
- **Fallback Methods**: Alternative approaches when primary methods fail
- **User Feedback**: Clear error messages with actionable solutions
#### **Common Error Scenarios**
- Network connectivity issues on mobile devices
- API rate limiting and quota exhaustion
- Invalid or unavailable YouTube content
- LLM API failures and timeout handling
- Template processing errors
### Performance Optimization
#### **Adaptive Processing**
- Content complexity analysis for model selection
- Platform-specific optimizations (mobile vs desktop)
- Dynamic chunk size adjustment based on content length
- Memory usage optimization for large transcripts
#### **Monitoring and Analytics**
- Real-time performance tracking
- Historical performance data
- Bottleneck identification and resolution
- Optimization suggestions based on usage patterns
This comprehensive workflow ensures that TubeSage provides a reliable, efficient, and user-friendly experience while maintaining high-quality output across all supported platforms and use cases.

View file

@ -0,0 +1,184 @@
# YouTube Transcript Extraction (TubeSage) — Findings & Next Steps
## TL;DR
TubeSages “local transcript” extraction relied on YouTubes **private** `youtubei` endpoints.
- **Phase 1 (WEB / ScrapeCreators method):** call `youtubei/v1/next` to discover transcript params, then call `youtubei/v1/get_transcript`.
- **Phase 2 (ANDROID spoof):** call `youtubei/v1/player` as an Android client to obtain `captionTracks[].baseUrl`, then download captions via that URL.
- **Current state (as of 2026-03-05):** both `youtubei/v1/player` (ANDROID) and `youtubei/v1/get_transcript` (WEB) are failing with `HTTP 400` `FAILED_PRECONDITION` (“Precondition check failed.”). This appears to be YouTube tightening request integrity/session requirements, making the private API approach brittle.
This document captures what we implemented, how it evolved, whats failing now, and the recommended path forward.
---
## Context
TubeSage extracts transcripts to:
- create timestamped notes in Obsidian
- feed the transcript into LLM summarization
- support multiple fallbacks when YouTube transcript retrieval fails
All network requests go through Obsidians `requestUrl` via the shim:
- `src/utils/fetch-shim.ts`
The transcript code lives in:
- `src/youtube-transcript.ts`
---
## Phase 1 — WEB transcript extraction (ScrapeCreators two-step approach)
Reference article (conceptual basis, not copied here):
- https://scrapecreators.com/blog/how-to-scrape-youtube-transcripts-with-node-js-in-2025
### How it works (TubeSage implementation)
1. **Fetch the watch page** (`/watch?v=VIDEO_ID`) to extract dynamic config:
- `INNERTUBE_API_KEY`
- `INNERTUBE_CLIENT_VERSION`
- `VISITOR_DATA`
Implemented in `YouTubeTranscriptExtractor.getYouTubeConfig()`:
- `src/youtube-transcript.ts`
2. **Call `youtubei/v1/next`** to get transcript “params”:
- POST `https://www.youtube.com/youtubei/v1/next?prettyPrint=false`
- body includes `{ context: { client: { clientName: "WEB", clientVersion, visitorData } }, videoId }`
- then recursively search the JSON for `getTranscriptEndpoint.params`
3. **Call `youtubei/v1/get_transcript`** with those params:
- POST `https://www.youtube.com/youtubei/v1/get_transcript?prettyPrint=false`
- body includes `{ context: { client: { clientName: "WEB", clientVersion, visitorData } }, params }`
- parse transcript segments from the returned structure (`cueGroups`, `initialSegments`, etc.)
### Why we moved away from this approach
This is a private API and historically has been prone to returning `HTTP 400` responses (even when `next` succeeds). The code comments already acknowledge this brittleness:
- “WEB client with ScrapeCreators method … often fails with HTTP 400”
---
## Phase 2 — ANDROID client spoof (Player API → captionTracks → timedtext download)
### Why this was added
When the WEB `get_transcript` flow became unreliable, we added a more reliable path:
- call `youtubei/v1/player` **as an ANDROID client**
- extract `captions.playerCaptionsTracklistRenderer.captionTracks[].baseUrl`
- download captions directly from that `baseUrl` (forcing `fmt=json3`)
This avoids `get_transcript` entirely and leverages the caption track URL YouTube returns to clients.
### How it works (TubeSage implementation)
1. **Call `youtubei/v1/player`** with Android client context:
- POST `https://www.youtube.com/youtubei/v1/player?key=<INNERTUBE_API_KEY>&prettyPrint=false`
- `context.client.clientName = "ANDROID"`
- Android UA + `X-Youtube-Client-Name: 3`
Implemented in:
- `YouTubeTranscriptExtractor.fetchViaPlayerApiAndroid()` (`src/youtube-transcript.ts`)
2. **Pick a caption track** (prefer matching `lang`, else first track).
3. **Fetch captions** from `captionTracks[].baseUrl`:
- add/replace `fmt=json3`
- parse JSON3 (or XML variants) into `TranscriptSegment[]`
Implemented in:
- `YouTubeTranscriptExtractor.fetchCaptionTrack()` (`src/youtube-transcript.ts`)
---
## Current failure (2026-03-05): `FAILED_PRECONDITION` on both paths
### Observed behavior
Recent logs show:
- `youtubei/v1/player` (ANDROID) → `HTTP 400` `FAILED_PRECONDITION`
- `youtubei/v1/next` (WEB) → `HTTP 200` (still succeeds)
- `youtubei/v1/get_transcript` (WEB) → `HTTP 400` `FAILED_PRECONDITION`
The error shape returned by YouTube looks like:
```json
{
"error": {
"code": 400,
"message": "Precondition check failed.",
"status": "FAILED_PRECONDITION"
}
}
```
### What this likely means
This isnt an LLM/provider issue; transcript extraction is failing upstream.
`FAILED_PRECONDITION` from `youtubei` generally indicates YouTube is rejecting requests that dont meet newer **integrity**, **session binding**, or **anti-automation** requirements. Because `youtubei` is private/unsupported, changes like this can (and do) happen without warning.
---
## Impact on TubeSage
When all extraction paths fail, TubeSage:
- may still return metadata (title/author) if it was successfully extracted
- writes a placeholder transcript line indicating extraction failure
Note: the placeholder message currently says “ANDROID, WEB, and Supadata methods all failed” even when Supadata wasnt configured (Supadata only runs when an API key is provided). This is just messaging accuracy, not the root cause.
---
## Recommended next steps (more robust, less “signature chasing”)
### 1) Prefer watch-page captions over `youtubei` (recommended)
Instead of calling private `youtubei` endpoints, fetch the watch HTML and parse:
- `ytInitialPlayerResponse.captions.playerCaptionsTracklistRenderer.captionTracks[].baseUrl`
TubeSage already parses `ytInitialPlayerResponse` for metadata in:
- `YouTubeTranscriptExtractor.getVideoMetadata()` (`src/youtube-transcript.ts`)
Extending that to retrieve caption tracks and download captions via `baseUrl` should be more resilient than continuing to chase `youtubei` preconditions.
### 2) Add a public captions fallback (`/api/timedtext`)
When `captionTracks` arent present in `ytInitialPlayerResponse`, attempt the public caption endpoints (language list + download) as a best-effort fallback.
### 3) Keep `youtubei` paths as “experimental”
If we keep ANDROID spoof / `get_transcript`:
- hide behind a toggle (default off)
- fail fast on `FAILED_PRECONDITION` (dont loop/retry)
- make the user-facing error explicit (“YouTube blocked internal API; try alternate extraction mode or a transcript service”)
### 4) Optional: third-party transcript service
TubeSage already includes a Supadata fallback when configured. This can remain a reliable alternative when YouTube blocks local extraction.
---
## Appendix — Code map
- Main orchestrator / fallbacks: `YouTubeTranscriptExtractor.fetchTranscript()` (`src/youtube-transcript.ts`)
- Config extraction from watch HTML: `YouTubeTranscriptExtractor.getYouTubeConfig()` (`src/youtube-transcript.ts`)
- WEB two-step (ScrapeCreators-style): `youtubei/v1/next``youtubei/v1/get_transcript` (within `fetchTranscript()`)
- ANDROID spoof via `youtubei/v1/player`: `YouTubeTranscriptExtractor.fetchViaPlayerApiAndroid()` (`src/youtube-transcript.ts`)
- Caption download + parsing: `YouTubeTranscriptExtractor.fetchCaptionTrack()` (`src/youtube-transcript.ts`)
- HTTP shim used everywhere: `obsidianFetch()` (`src/utils/fetch-shim.ts`)

54
esbuild.config.mjs Normal file
View file

@ -0,0 +1,54 @@
import esbuild from "esbuild";
import process from "process";
import { builtinModules } from "module";
const banner =
`/*
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
if you want to view the source, please visit the github repository of this plugin
*/
`;
const prod = process.argv[2] === "production";
const context = await esbuild.context({
banner: {
js: banner,
},
entryPoints: ["main.ts"],
bundle: true,
external: [
"obsidian",
"electron",
"@codemirror/autocomplete",
"@codemirror/collab",
"@codemirror/commands",
"@codemirror/language",
"@codemirror/lint",
"@codemirror/search",
"@codemirror/state",
"@codemirror/view",
"@lezer/common",
"@lezer/highlight",
"@lezer/lr",
...builtinModules,
],
format: "cjs",
target: "es2018",
logLevel: "info",
sourcemap: prod ? false : "inline",
treeShaking: true,
outfile: "main.js",
loader: {
'.css': 'text',
'.wasm': 'file'
},
plugins: [],
});
if (prod) {
await context.rebuild();
process.exit(0);
} else {
await context.watch();
}

View file

@ -0,0 +1,81 @@
// Local fix for eslint-plugin-obsidianmd's `prefer-active-doc` rule.
// Upstream bug: REPLACEMENTS[node.name] follows the prototype chain, so any
// Identifier whose name matches an Object.prototype key (constructor, toString,
// hasOwnProperty, ...) is falsely flagged. Guarding with Object.hasOwn fixes it.
const REPLACEMENTS = Object.freeze({
document: "activeDocument",
window: "activeWindow",
});
const BANNED_GLOBALS = new Set(["global", "globalThis"]);
function findVariable(scope, name) {
let current = scope;
while (current) {
const variable = current.variables.find((v) => v.name === name);
if (variable) return variable;
current = current.upper;
}
return null;
}
function isSkippableParent(node) {
const p = node.parent;
if (!p) return false;
if (p.type === "MemberExpression" && p.property === node) return true;
if (p.type === "Property" && p.key === node) return true;
if (p.type === "VariableDeclarator" && p.id === node) return true;
if (p.type === "UnaryExpression" && p.operator === "typeof") return true;
return false;
}
export default {
meta: {
type: "suggestion",
docs: {
description:
"Prefer `activeDocument` and `activeWindow` over `document` and `window` for popout window compatibility.",
},
schema: [],
fixable: "code",
messages: {
preferActive:
"Use '{{replacement}}' instead of '{{original}}' for popout window compatibility.",
avoidGlobal:
"Avoid using '{{name}}'. Use 'activeWindow' or 'activeDocument' for popout window compatibility.",
},
},
create(context) {
return {
Identifier(node) {
if (BANNED_GLOBALS.has(node.name)) {
if (isSkippableParent(node)) return;
const scope = context.sourceCode.getScope(node);
if (findVariable(scope, node.name)?.defs.length) return;
context.report({
node,
messageId: "avoidGlobal",
data: { name: node.name },
});
return;
}
if (!Object.hasOwn(REPLACEMENTS, node.name)) return;
if (isSkippableParent(node)) return;
const scope = context.sourceCode.getScope(node);
if (findVariable(scope, node.name)?.defs.length) return;
const replacement = REPLACEMENTS[node.name];
context.report({
node,
messageId: "preferActive",
data: { original: node.name, replacement },
fix(fixer) {
return fixer.replaceText(node, replacement);
},
});
},
};
},
};

71
eslint.config.mjs Normal file
View file

@ -0,0 +1,71 @@
import obsidianmd from "eslint-plugin-obsidianmd";
import tseslint from "typescript-eslint";
import globals from "globals";
import json from "@eslint/json";
import preferActiveDocFixed from "./eslint-rules/prefer-active-doc-fixed.mjs";
// Upstream eslint-plugin-obsidianmd v0.2.3 `prefer-active-doc` has a prototype-
// lookup bug (REPLACEMENTS[node.name] walks the prototype chain, so every class
// `constructor` is falsely flagged). Swap the rule implementation in-place on
// the plugin object before it gets registered.
if (obsidianmd.rules) {
obsidianmd.rules["prefer-active-doc"] = preferActiveDocFixed;
}
export default tseslint.config(
{
ignores: [
"main.js",
"node_modules",
"*.config.mjs",
"*.config.js",
"esbuild.config.mjs",
"eslint-rules/**",
"package-lock.json",
"tsconfig*.json",
".claude/**",
],
},
...obsidianmd.configs.recommended,
{
languageOptions: {
parserOptions: { project: "./tsconfig.eslint.json" },
globals: {
...globals.browser,
...globals.node,
createDiv: "readonly",
createSpan: "readonly",
},
},
},
{
files: ["**/*.ts", "**/*.tsx"],
rules: {
"obsidianmd/ui/sentence-case": [
"error",
{ enforceCamelCaseLower: true, allowAutoFix: true },
],
},
},
// Disable typed linting for JSON (plugin applies typed rules globally — needs override)
{
files: ["**/*.json"],
extends: [tseslint.configs.disableTypeChecked],
rules: {
"obsidianmd/no-plugin-as-component": "off",
"obsidianmd/no-unsupported-api": "off",
"obsidianmd/no-view-references-in-plugin": "off",
"obsidianmd/prefer-file-manager-trash-file": "off",
"obsidianmd/prefer-instanceof": "off",
},
},
// manifest.json needs json/json language (plugin only wires package.json)
{
files: ["manifest.json"],
plugins: { json },
language: "json/json",
rules: {
"no-irregular-whitespace": "off",
},
}
);

6542
main.ts Normal file

File diff suppressed because one or more lines are too long

10
manifest.json Normal file
View file

@ -0,0 +1,10 @@
{
"id": "tubesage",
"name": "TubeSage",
"version": "1.2.21",
"minAppVersion": "1.2.0",
"description": "Create comprehensive notes from YouTube transcripts using LLMs.",
"author": "Richard McCorkle",
"authorUrl": "https://github.com/rmccorkl",
"isDesktopOnly": false
}

6507
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

57
package.json Normal file
View file

@ -0,0 +1,57 @@
{
"name": "tubesage",
"version": "1.2.21",
"description": "Create comprehensive notes from YouTube transcripts using LLMs.",
"main": "main.js",
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"deploy": "chmod +x scripts/deploy.sh && ./scripts/deploy.sh",
"lint": "eslint ."
},
"keywords": [
"obsidian",
"youtube",
"transcript",
"llm",
"templater"
],
"author": "Richard McCorkle",
"license": "MIT",
"devDependencies": {
"@eslint/js": "9.39.4",
"@eslint/json": "0.14.0",
"@types/express": "^5.0.3",
"@types/node": "^24.3.0",
"@typescript-eslint/eslint-plugin": "^8.39.1",
"@typescript-eslint/parser": "^8.39.1",
"adm-zip": "^0.5.16",
"esbuild": "^0.27.2",
"eslint": "^9.39.2",
"eslint-plugin-no-unsanitized": "4.1.5",
"eslint-plugin-obsidianmd": "0.2.9",
"obsidian": "^1.8.7",
"tslib": "^2.8.1",
"typescript": "^5.9.2",
"typescript-eslint": "8.58.2"
},
"overrides": {
"axios": ">=1.15.0",
"langsmith": "^0.5.18",
"fast-xml-parser": ">=5.7.0",
"uuid": ">=14.0.0"
},
"dependencies": {
"@anthropic-ai/tokenizer": "^0.0.4",
"@langchain/anthropic": "^0.3.26",
"@langchain/core": "^0.3.17",
"@langchain/google-genai": "^0.2.16",
"@langchain/ollama": "^0.2.0",
"@langchain/openai": "^0.6.7",
"hh-mm-ss": "^1.2.0",
"langchain": "^0.3.30",
"node-html-parser": "^7.0.1",
"openai": "^4.77.0",
"parse-duration": "^2.1.4"
}
}

141
src/llm/gemini-client.ts Normal file
View file

@ -0,0 +1,141 @@
import { obsidianFetch } from "../utils/fetch-shim";
import { getLogger } from "../utils/logger";
const logger = getLogger('GEMINI');
interface GeminiGenerateOptions {
temperature?: number;
max_tokens?: number;
top_p?: number;
top_k?: number;
system?: string;
}
const isRecord = (value: unknown): value is Record<string, unknown> => {
return typeof value === 'object' && value !== null;
};
/**
* Client for Google's Gemini API
*/
export class GeminiClient {
private apiKey: string;
private baseUrl = "https://generativelanguage.googleapis.com";
private apiVersion = "v1beta"; // v1beta required for Gemini 2.x models
constructor(apiKey: string) {
if (!apiKey || apiKey.trim() === '') {
throw new Error('Google API key is required');
}
this.apiKey = apiKey;
logger.debug('Creating Gemini client');
}
/**
* Check if the client can be used on the current platform
*/
isAvailable(): boolean {
// Gemini should work on all platforms through our fetch shim
return true;
}
/**
* Generate content using a Gemini model
*
* @param model The model to use (e.g., "gemini-1.5-pro")
* @param prompt The text prompt
* @param options Additional options
* @returns The generation response
*/
async generateContent(model: string, prompt: string, options: GeminiGenerateOptions = {}): Promise<unknown> {
try {
const url = `${this.baseUrl}/${this.apiVersion}/models/${model}:generateContent?key=${this.apiKey}`;
// Initialize request body
const requestBody: {
system_instruction?: { parts: Array<{ text: string }> };
contents: Array<{ role: string; parts: Array<{ text: string }> }>;
generationConfig: {
temperature: number;
maxOutputTokens: number;
topP: number;
topK: number;
};
} = {
contents: [],
generationConfig: {
temperature: options.temperature !== undefined ? options.temperature : 0.7,
maxOutputTokens: options.max_tokens || 1024,
topP: options.top_p || 0.95,
topK: options.top_k || 40
}
};
// Use system_instruction field (supported since mid-2024 in v1beta)
if (options.system) {
requestBody.system_instruction = { parts: [{ text: options.system }] };
logger.debug('Added system_instruction to Gemini request');
}
requestBody.contents.push({
role: "user",
parts: [{ text: prompt }]
});
const response = await obsidianFetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(requestBody)
});
if (!response.ok) {
const errorData = await response.json().catch(() => null) as unknown;
logger.error('Gemini API error:', errorData);
let errorMessage = `HTTP ${response.status}`;
if (isRecord(errorData)) {
const nestedError = errorData.error;
if (isRecord(nestedError) && typeof nestedError.message === 'string') {
errorMessage = nestedError.message;
} else if (typeof errorData.message === 'string') {
errorMessage = errorData.message;
}
}
throw new Error(`Gemini API error: ${errorMessage}`);
}
return await response.json() as unknown;
} catch (error) {
logger.error('Error in generateContent:', error);
throw error;
}
}
/**
* Extract the generated text from a Gemini API response
*
* @param response The raw API response
* @returns The generated text
*/
extractText(response: unknown): string {
try {
if (!isRecord(response)) {
return '';
}
const candidates = response.candidates;
if (!Array.isArray(candidates) || !candidates[0]) {
return '';
}
const candidate = candidates[0] as {
content?: { parts?: Array<{ text?: string }> };
};
const text = candidate.content?.parts?.[0]?.text;
return typeof text === 'string' ? text : '';
} catch (error) {
logger.error('Error extracting text from Gemini response:', error);
return '';
}
}
}

314
src/llm/langchain-client.ts Normal file
View file

@ -0,0 +1,314 @@
import { ChatOpenAI } from "@langchain/openai";
import { ChatOllama } from "@langchain/ollama";
import { GeminiClient } from "./gemini-client";
import { SystemMessage, HumanMessage } from "@langchain/core/messages";
import { getLogger } from "../utils/logger";
import { getSafeErrorMessage } from "../utils/error-utils";
import { getLangChainConfiguration } from "./langchain-fetcher";
import { obsidianFetch } from "../utils/fetch-shim";
const logger = getLogger('LANGCHAIN');
type UnknownRecord = Record<string, unknown>;
const isRecord = (value: unknown): value is UnknownRecord =>
typeof value === 'object' && value !== null;
/**
* Helper function to truncate content for debug logging
*/
function truncateForDebug(content: string, maxLength: number = 200): string {
if (content.length <= maxLength) {
return content;
}
return content.substring(0, maxLength) + '...';
}
const extractContent = (response: unknown): string => {
if (response && typeof response === 'object' && 'content' in response) {
const content = (response as { content?: unknown }).content;
if (typeof content === 'string') return content;
if (content === null || content === undefined) return '';
return JSON.stringify(content);
}
if (typeof response === 'string') return response;
if (response === null || response === undefined) return '';
return JSON.stringify(response);
};
// Detect OpenAI reasoning (o-series) models which require max_completion_tokens
const isReasoningModel = (model: string): boolean => {
if (!model) return false;
if (/^o\d/i.test(model)) return true;
if (model.startsWith('gpt-5') && !model.startsWith('gpt-5-chat')) return true;
return false;
};
/**
* A unified client for multiple LLM providers using LangChain
*/
export class LangChainClient {
private provider: string;
private model: string;
private apiKey: string;
private temperature: number;
private maxTokens: number;
constructor(options: {
provider: string;
model: string;
apiKey: string;
temperature?: number;
maxTokens?: number;
}) {
this.provider = options.provider;
this.model = options.model;
this.apiKey = options.apiKey;
this.temperature = options.temperature ?? 0.7;
this.maxTokens = options.maxTokens ?? 1024;
// Enhanced debugging for Google provider
logger.debug(`[CONSTRUCTOR] Creating LangChain client for provider: '${this.provider}' with model: '${this.model}'`);
logger.debug(`[CONSTRUCTOR] Full options object:`, JSON.stringify(options, null, 2));
logger.debug(`[CONSTRUCTOR] Model type: ${typeof this.model}, Model value: ${this.model}`);
logger.debug(`[CONSTRUCTOR] API Key present: ${!!this.apiKey}, API Key length: ${this.apiKey?.length || 0}`);
// Special validation for Google provider
if (this.provider === 'google') {
logger.debug(`[GOOGLE] Model validation - is undefined: ${this.model === undefined}, is null: ${this.model === null}, is empty string: ${this.model === ''}`);
if (!this.model || this.model === 'undefined' || this.model === 'null') {
logger.error(`[GOOGLE] CRITICAL: Model is invalid for Google provider: '${this.model}'`);
throw new Error(`Invalid model for Google provider: '${this.model}'. Expected a valid Gemini model ID like 'gemini-1.5-pro'`);
}
}
}
/**
* Generate a completion using the appropriate LangChain model
*/
async generateCompletion(systemPrompt: string, userPrompt: string): Promise<string> {
try {
// Common configuration with our custom fetcher
const config = getLangChainConfiguration({
apiKey: this.apiKey
});
// Create messages in LangChain format
const messages = [
new SystemMessage(systemPrompt),
new HumanMessage(userPrompt)
];
switch (this.provider) {
case 'openai': {
logger.debug(`Using OpenAI with model ${this.model}`);
// Special handling for GPT-5 temperature restrictions
let effectiveTemperature = this.temperature;
if (this.model === 'gpt-5') {
effectiveTemperature = 1; // GPT-5 only supports temperature=1
logger.debug(`GPT-5 detected: forcing temperature to 1 (was ${this.temperature})`);
}
const useMaxCompletionTokens = isReasoningModel(this.model);
if (useMaxCompletionTokens) {
logger.debug(`Reasoning model detected; using maxCompletionTokens to comply with OpenAI o-series requirements.`);
}
// OpenAI: use maxCompletionTokens for o-series, fall back to maxTokens otherwise
const model = new ChatOpenAI({
...config,
modelName: this.model,
...(useMaxCompletionTokens
? { maxCompletionTokens: this.maxTokens }
: { maxTokens: this.maxTokens }),
temperature: effectiveTemperature
});
// TODO: Revisit this type casting when LangChain's type definitions are more stable
// Type cast to any[] is needed because LangChain's type definitions for invoke()
// expect an array type that's not directly compatible with (SystemMessage | HumanMessage)[]
const response = await model.invoke(messages);
return String(extractContent(response));
}
case 'anthropic': {
logger.debug(`Using Anthropic with model ${this.model}`);
// Call Anthropic directly with our shim instead of using their SDK
// This bypasses their browser environment detection completely
try {
// Extract original structured content
let systemPromptContent = '';
let userPromptContent = '';
// Get content from messages
for (const msg of messages) {
const getContent = (m: SystemMessage | HumanMessage | string | object | null): string => {
if (typeof m === 'string') return m;
if (m === null || typeof m !== 'object') return String(m);
if ('content' in m) {
const c = (m as { content?: unknown }).content;
if (typeof c === 'string') return c;
try {
return JSON.stringify(c);
} catch {
return String(c);
}
}
if ('text' in m) {
const textVal = (m as { text?: unknown }).text;
return typeof textVal === 'string' ? textVal : JSON.stringify(textVal);
}
if ('value' in m) {
const valueVal = (m as { value?: unknown }).value;
return typeof valueVal === 'string' ? valueVal : JSON.stringify(valueVal);
}
return JSON.stringify(m);
};
if (msg instanceof SystemMessage) {
systemPromptContent = getContent(msg);
} else if (msg instanceof HumanMessage) {
userPromptContent = getContent(msg);
}
}
// Anthropic requires system as a top-level parameter and user messages in the array
// Make sure the user message preserves the structured format
const formattedMessages = [
{ role: "user", content: userPromptContent }
];
// Prepare the API request with system message as a top-level parameter
const payload = {
model: this.model,
messages: formattedMessages,
max_tokens: this.maxTokens,
temperature: this.temperature,
system: systemPromptContent // Anthropic requires system as a top-level parameter
};
// Debug logging for Anthropic - aligned with other providers
logger.debug(`Anthropic API Request - Model: ${this.model}`);
logger.debug(`System prompt (${systemPromptContent.length} chars): ${truncateForDebug(systemPromptContent)}`);
logger.debug(`User messages (${formattedMessages.length}): ${truncateForDebug(userPromptContent)}`);
logger.debug(`Temperature: ${this.temperature}, MaxTokens: ${this.maxTokens}`);
// Make the request directly using our fetch shim
const response = await obsidianFetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': this.apiKey,
'anthropic-version': '2023-06-01'
},
body: JSON.stringify(payload)
});
// Parse the response
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Anthropic API error: ${errorText}`);
}
const responseData = await response.json() as unknown;
// Debug logging for response - truncated
const usage = isRecord(responseData) ? responseData.usage : undefined;
logger.debug(`Anthropic API Response: ${usage ? `Usage: ${JSON.stringify(usage)}` : 'Success'}`);
const content = isRecord(responseData) ? responseData.content : undefined;
if (!Array.isArray(content) || !content[0] || !isRecord(content[0]) || typeof content[0].text !== 'string') {
throw new Error('Invalid response format from Anthropic API');
}
const responseText = content[0].text;
logger.debug(`Anthropic response text (${responseText.length} chars): ${truncateForDebug(responseText)}`);
return responseText;
} catch (error) {
logger.error('Error with direct Anthropic API call:', error);
throw error;
}
}
case 'google': {
logger.debug(`[GOOGLE] Using GeminiClient (direct obsidianFetch) for model: '${this.model}'`);
logger.debug(`[GOOGLE] Temperature: ${this.temperature}, MaxTokens: ${this.maxTokens}`);
logger.debug(`[GOOGLE] API Key present: ${!!this.apiKey}, length: ${this.apiKey?.length || 0}`);
if (!this.model || typeof this.model !== 'string' || this.model.trim() === '') {
logger.error(`[GOOGLE] CRITICAL: Invalid model name: '${this.model}'`);
throw new Error(`Invalid Google model name: '${this.model}'. Expected a valid Gemini model ID.`);
}
// Use GeminiClient directly with obsidianFetch — ChatGoogleGenerativeAI ignores the
// custom fetch override so it breaks on mobile. Direct calls mirror the Anthropic pattern.
const geminiClient = new GeminiClient(this.apiKey);
const systemContent = extractContent(messages[0]);
const userContent = extractContent(messages[1]);
const geminiResponse = await geminiClient.generateContent(this.model, userContent, {
temperature: this.temperature,
max_tokens: this.maxTokens,
system: systemContent
});
return geminiClient.extractText(geminiResponse);
}
case 'ollama': {
logger.debug(`Using Ollama with model ${this.model}`);
// For Ollama, the API key is actually the base URL
const model = new ChatOllama({
...config,
baseUrl: this.apiKey, // Ollama uses the API key field to store the base URL
model: this.model,
temperature: this.temperature,
});
// Type cast needed for compatibility with LangChain's invoke() method
const response = await model.invoke(messages);
return String(extractContent(response));
}
default:
throw new Error(`Unsupported LangChain provider: ${this.provider}`);
}
} catch (error) {
logger.error(`Error in LangChain ${this.provider} completion:`, error);
// Log comprehensive error details
logger.error(`Error type: ${typeof error}`);
const errorMessage = getSafeErrorMessage(error);
logger.error(`Error message: ${errorMessage}`);
if (error instanceof Error && error.stack) {
logger.error(`Error stack: ${error.stack}`);
}
// Log all error properties
if (isRecord(error)) {
const errorKeys = Object.keys(error);
if (errorKeys.length > 0) {
logger.error(`Error keys: ${errorKeys.join(', ')}`);
errorKeys.forEach((key) => {
logger.error(`Error.${key}:`, error[key]);
});
}
const response = error.response;
if (isRecord(response)) {
const status = response.status;
const data = response.data;
logger.error(`Status: ${typeof status === 'number' ? status : 'unknown'}, Data:`, data);
}
}
if (errorMessage.includes('ERR_INVALID_ARGUMENT')) {
throw new Error(`Invalid request to ${this.provider} API. Please check your API key and network connection.`);
}
throw error;
}
}
}

View file

@ -0,0 +1,93 @@
import { obsidianFetch } from "../utils/fetch-shim";
import { getLogger } from "../utils/logger";
const logger = getLogger('LANGCHAIN_FETCHER');
/**
* Custom fetcher for LangChain that uses our Obsidian fetch shim
* This makes LangChain models work on both desktop and mobile Obsidian
*/
export function createLangChainFetcher() {
return async (url: string | URL | Request, init?: RequestInit): Promise<Response> => {
try {
// Ensure the URL is properly formatted
let requestUrl: string;
if (typeof url === 'string') {
requestUrl = url;
} else if (url instanceof URL) {
requestUrl = url.toString();
} else if (url instanceof Request) {
requestUrl = url.url;
} else {
logger.error('Invalid URL type:', typeof url);
throw new TypeError('Invalid URL type');
}
// Sanitize URL and check for API-specific issues
try {
const parsedUrl = new URL(requestUrl);
// Ensure protocol is http or https
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
throw new Error(`Invalid protocol: ${parsedUrl.protocol}`);
}
// Special case for OpenAI API - ensure we have a valid path
// (this was causing ERR_INVALID_ARGUMENT)
if (parsedUrl.hostname.includes('openai.com')) {
logger.debug('OpenAI API detected, ensuring valid path');
// Fix common path errors
if (!parsedUrl.pathname || parsedUrl.pathname === '/') {
throw new Error('Invalid OpenAI API path');
}
// Ensure API version is included
if (!parsedUrl.pathname.includes('/v1/')) {
throw new Error('OpenAI API requires /v1/ path');
}
}
// Use the validated URL
requestUrl = parsedUrl.toString();
} catch (e) {
logger.error(`Invalid URL: ${requestUrl}`, e);
throw new TypeError(`Invalid URL: ${requestUrl}`);
}
// Log request for debugging
logger.debug(`LangChain fetch: ${requestUrl}`);
// Additional checks for common API issues
if (init?.headers) {
const headers = init.headers as Record<string, string>;
// Check for missing content-type on POST requests
if (init.method === 'POST' && init.body && !headers['Content-Type']) {
logger.debug('Adding Content-Type header for POST request');
headers['Content-Type'] = 'application/json';
}
// Log authorization header presence (not the value) for debugging
logger.debug(`Authorization header present: ${!!headers['Authorization']}`);
}
// Use our obsidianFetch shim for the actual request
return obsidianFetch(requestUrl, init);
} catch (error) {
logger.error("LangChain fetch error:", error);
throw error;
}
};
}
/**
* Configuration object for LangChain models that includes our custom fetcher
*/
export function getLangChainConfiguration(options: Record<string, unknown> = {}) {
return {
...options,
fetch: createLangChainFetcher()
};
}

45
src/llm/llm-factory.ts Normal file
View file

@ -0,0 +1,45 @@
import { getLogger } from "../utils/logger";
import { OllamaClient } from "./ollama-client";
const logger = getLogger('LLM_FACTORY');
/**
* Settings interface needed by the factory
*/
export interface LLMSettings {
selectedLLM: string;
apiKeys: Record<string, string>;
selectedModels: Record<string, string>;
}
/**
* Factory for the Ollama client. Other providers go through LangChain
* (`LangChainClient`) so they don't need factory indirection.
*/
export class LLMFactory {
private settings: LLMSettings;
private ollamaClient: OllamaClient | null = null;
constructor(settings: LLMSettings) {
this.settings = settings;
logger.debug('Created LLM Factory');
}
getBestProvider(): string {
return this.settings.selectedLLM;
}
getOllamaClient(): OllamaClient {
if (!this.ollamaClient) {
const baseUrl = this.settings.apiKeys['ollama'] || 'http://localhost:11434';
this.ollamaClient = new OllamaClient(baseUrl);
}
return this.ollamaClient;
}
updateSettings(settings: LLMSettings): void {
this.settings = settings;
this.ollamaClient = null;
logger.debug('LLM Factory settings updated');
}
}

205
src/llm/ollama-client.ts Normal file
View file

@ -0,0 +1,205 @@
import { obsidianFetch } from "../utils/fetch-shim";
import { getLogger } from "../utils/logger";
const logger = getLogger('OLLAMA');
interface OllamaGenerateResponse {
response?: string;
prompt_eval_count?: number;
eval_count?: number;
}
interface OllamaChatCompletion {
id: string;
object: string;
created: number;
model: string;
choices: Array<{
index: number;
message: {
role: string;
content: string;
};
finish_reason: string;
}>;
usage: {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
};
}
/**
* Simplified Ollama API client that works with local Ollama instances
* Using the bare-bones HTTP API instead of SDKs for maximum compatibility
*/
export class OllamaClient {
private baseUrl: string;
constructor(baseUrl: string = 'http://localhost:11434') {
this.baseUrl = baseUrl;
logger.debug('Ollama client created with base URL:', baseUrl);
}
/**
* Check if Ollama can be used on the current platform
*/
isAvailable(): boolean {
// Ollama should work on all platforms with the unified fetch shim
// However, users need to ensure their Ollama server is accessible
// from their device (typically via localhost on same network)
return true;
}
/**
* Validate that the Ollama server is accessible
*/
async validateConnection(): Promise<boolean> {
try {
const response = await obsidianFetch(`${this.baseUrl}/api/version`);
if (!response.ok) {
logger.error(`Ollama server returned error status: ${response.status}`);
return false;
}
const data = await response.json() as unknown;
logger.debug('Ollama version check successful:', data);
return true;
} catch (error) {
logger.error('Ollama server connection failed:', error);
return false;
}
}
/**
* Generate a completion from Ollama
*/
async generateCompletion(
model: string,
prompt: string,
options: {
system?: string;
temperature?: number;
max_tokens?: number;
} = {}
): Promise<OllamaGenerateResponse> {
const { system, temperature = 0.7, max_tokens } = options;
try {
const requestBody: {
model: string;
prompt: string;
stream: boolean;
system?: string;
options: {
temperature: number;
num_predict?: number;
};
} = {
model,
prompt,
stream: false,
options: {
temperature
}
};
// Add optional parameters if provided
if (system) {
requestBody.system = system;
}
if (max_tokens) {
requestBody.options.num_predict = max_tokens;
}
const response = await obsidianFetch(`${this.baseUrl}/api/generate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(requestBody)
});
if (!response.ok) {
const errorText = await response.text();
logger.error('Ollama API error:', errorText);
throw new Error(`Ollama API error: ${response.status} - ${errorText}`);
}
const data = await response.json() as OllamaGenerateResponse;
return data;
} catch (error) {
logger.error('Error in Ollama generateCompletion:', error);
throw error;
}
}
/**
* Create a chat completion - wrapper around generate for more OpenAI-like interface
*/
async createChatCompletion(
model: string,
messages: Array<{role: string; content: string}>,
options: {
temperature?: number;
max_tokens?: number;
system?: string;
} = {}
): Promise<OllamaChatCompletion> {
try {
// Extract system message if present
let systemPrompt = options.system || '';
if (!systemPrompt && messages.length > 0 && messages[0].role === 'system') {
systemPrompt = messages[0].content;
messages = messages.slice(1);
}
// Format the messages into a prompt
let prompt = '';
messages.forEach(message => {
if (message.role === 'user') {
prompt += `\nHuman: ${message.content}`;
} else if (message.role === 'assistant') {
prompt += `\nAssistant: ${message.content}`;
}
});
// Add final turn
prompt += '\nAssistant:';
// Generate completion
const result = await this.generateCompletion(model, prompt, {
system: systemPrompt,
temperature: options.temperature,
max_tokens: options.max_tokens
});
// Format the response to match OpenAI structure
return {
id: 'ollama-' + Date.now(),
object: 'chat.completion',
created: Math.floor(Date.now() / 1000),
model,
choices: [
{
index: 0,
message: {
role: 'assistant',
content: result.response ?? ''
},
finish_reason: 'stop'
}
],
usage: {
prompt_tokens: result.prompt_eval_count ?? 0,
completion_tokens: result.eval_count ?? 0,
total_tokens: (result.prompt_eval_count ?? 0) + (result.eval_count ?? 0)
}
};
} catch (error) {
logger.error('Error in Ollama createChatCompletion:', error);
throw error;
}
}
}

View file

@ -0,0 +1,230 @@
import { getLogger } from '../utils/logger';
import { LLMFactory } from './llm-factory';
import { LangChainClient } from './langchain-client';
interface LLMConfig {
model: string;
temperature: number;
maxTokens: number;
systemPrompt: string;
userPrompt: string;
}
// Get the logger for LLM operations
const llmLogger = getLogger('LLM');
export class TranscriptSummarizer {
private config: LLMConfig;
private apiKeys: Record<string, string>;
private llmFactory: LLMFactory;
constructor(config: LLMConfig, apiKeys: Record<string, string>) {
// Validate config
if (!config) {
llmLogger.error('TranscriptSummarizer: config is null or undefined');
throw new Error('TranscriptSummarizer: config is required');
}
// Add safety checks for config properties
this.config = {
model: config.model || 'gemini-1.5-pro',
temperature: config.temperature !== undefined ? config.temperature : 0.7,
maxTokens: config.maxTokens || 4096,
systemPrompt: config.systemPrompt || 'You are a helpful assistant.',
userPrompt: config.userPrompt || 'Please process the following content:'
};
this.apiKeys = apiKeys;
// Validate that essential config properties are present
if (!this.config.model) {
llmLogger.error('TranscriptSummarizer: model is missing from config');
}
if (this.config.maxTokens === undefined || this.config.maxTokens === null) {
llmLogger.error('TranscriptSummarizer: maxTokens is missing from config');
}
// Initialize the LLM factory
this.llmFactory = new LLMFactory({
selectedLLM: 'openai', // Default, will be overridden in summarize()
apiKeys: this.apiKeys,
selectedModels: {
openai: this.config.model,
anthropic: this.config.model,
google: this.config.model,
ollama: this.config.model
}
});
llmLogger.debug('TranscriptSummarizer initialized with config:', {
model: this.config.model,
temperature: this.config.temperature,
maxTokens: this.config.maxTokens,
hasSystemPrompt: !!this.config.systemPrompt,
hasUserPrompt: !!this.config.userPrompt
});
}
async summarize(transcript: string, provider: string): Promise<string> {
try {
// Validate inputs
if (!provider || provider.trim() === '') {
llmLogger.error('TranscriptSummarizer.summarize: provider is empty or undefined');
throw new Error('Provider is required for summarization');
}
if (!transcript || transcript.trim() === '') {
llmLogger.error('TranscriptSummarizer.summarize: transcript is empty or undefined');
throw new Error('Transcript is required for summarization');
}
llmLogger.info(`Starting summarization with provider: ${provider}`);
llmLogger.info(`Transcript length: ${transcript.length}`);
llmLogger.info(`Model: ${this.config.model}`);
llmLogger.info(`Temperature: ${this.config.temperature}`);
llmLogger.info(`Max tokens: ${this.config.maxTokens}`);
// Check if the API key exists before proceeding
if (!this.apiKeys[provider] || this.apiKeys[provider].trim() === '') {
llmLogger.error(`API key missing for provider: ${provider}`);
throw new Error(`API key for ${provider} is missing or empty.`);
}
// Important: Check if the transcript contains reference material section
// If it does, we need to extract the user prompt and transcript
let userPrompt = this.config.userPrompt;
let actualTranscript = transcript;
// Use a regex pattern without the 's' flag (which is ES2018+)
// Instead use [\s\S]* to match any character including newlines
const referencePattern = /-{5,}\s*REFERENCE MATERIAL[\s\S]*?-{5,}\s*END REFERENCE MATERIAL\s*-{5,}/;
const referenceMatch = transcript.match(referencePattern);
if (referenceMatch) {
llmLogger.debug("Detected reference material in transcript - extracting actual content");
// Extract the actual content from the transcript (content before the reference material)
const parts = transcript.split(referencePattern);
if (parts.length > 0) {
// The content before the reference section contains the actual prompt + content
const contentParts = parts[0].trim().split("\n\n");
// Extract user prompt if it's at the beginning (format matches expected)
if (contentParts.length >= 2) {
userPrompt = contentParts[0];
actualTranscript = contentParts.slice(1).join("\n\n");
llmLogger.debug("Extracted user prompt and content from transcript");
} else {
// Just use everything before reference as the content
actualTranscript = parts[0].trim();
llmLogger.debug("Using content before reference section as transcript");
}
}
}
// Special handling for Ollama which doesn't use LangChain integration
if (provider === 'ollama') {
return this.summarizeWithOllama(actualTranscript);
}
// For all other providers, use LangChain
return this.summarizeWithLangChain(actualTranscript, provider, userPrompt);
} catch (error) {
llmLogger.error("Error in summarize:", error);
throw error;
}
}
/**
* Use LangChain for summarization with OpenAI, Anthropic, or Google
*/
private async summarizeWithLangChain(transcript: string, provider: string, userPrompt?: string): Promise<string> {
try {
llmLogger.info(`Using LangChain for ${provider} summarization`);
// Enhanced debugging for Google provider tracking
llmLogger.debug(`[summarizeWithLangChain] Provider: '${provider}'`);
llmLogger.debug(`[summarizeWithLangChain] Model from config: '${this.config.model}'`);
llmLogger.debug(`[summarizeWithLangChain] Model type: ${typeof this.config.model}`);
llmLogger.debug(`[summarizeWithLangChain] API Key present for ${provider}: ${!!this.apiKeys[provider]}`);
llmLogger.debug(`[summarizeWithLangChain] Temperature: ${this.config.temperature}, MaxTokens: ${this.config.maxTokens}`);
llmLogger.debug(`[summarizeWithLangChain] Full config object:`, JSON.stringify(this.config, null, 2));
const langChainClient = new LangChainClient({
provider: provider,
model: this.config.model,
apiKey: this.apiKeys[provider],
temperature: this.config.temperature,
maxTokens: this.config.maxTokens
});
const promptToUse = userPrompt || this.config.userPrompt;
const userPromptWithTranscript = `${promptToUse}\n\nTranscript:\n${transcript}`;
const result = await langChainClient.generateCompletion(
this.config.systemPrompt,
userPromptWithTranscript
);
return result;
} catch (error) {
llmLogger.error(`Error in LangChain summarization with ${provider}:`, error);
throw error;
}
}
/**
* Use the Ollama client directly (not supported by LangChain in this implementation)
*/
private async summarizeWithOllama(transcript: string): Promise<string> {
const client = this.llmFactory.getOllamaClient();
// Check if server is running
const isRunning = await client.validateConnection();
if (!isRunning) {
throw new Error("Ollama server is not accessible. Please ensure Ollama is running and accessible.");
}
// Try the chat completion API first (better for newer models)
try {
const response = await client.createChatCompletion(
this.config.model,
[
{ role: "system", content: this.config.systemPrompt },
{ role: "user", content: this.config.userPrompt + "\n\nTranscript:\n" + transcript }
],
{
temperature: this.config.temperature,
max_tokens: this.config.maxTokens
}
);
return response.choices[0].message.content || "";
} catch (chatError) {
// Fallback to simplified approach
llmLogger.warn("Ollama chat completion failed:", chatError);
throw new Error("Ollama chat completion failed. Please check your configuration and ensure Ollama is running.");
}
}
/**
* Format the prompt based on the provider
*/
private formatPrompt(transcript: string, provider: string): string {
// Different providers may need different prompt formats
const basePrompt = `${this.config.userPrompt}\n\nTranscript:\n${transcript}`;
switch (provider) {
case 'anthropic':
// Claude tends to work better with more explicit instructions
return `${this.config.systemPrompt}\n\n${basePrompt}\n\nPlease provide a comprehensive and well-structured summary.`;
case 'ollama':
// Local models often need more explicit prompting
return `${this.config.systemPrompt}\n\n${basePrompt}\n\nSummarize the transcript above in a clear, structured format.`;
default:
return basePrompt;
}
}
}

23
src/types/electron-remote.d.ts vendored Normal file
View file

@ -0,0 +1,23 @@
// Type definitions for @electron/remote (optional dependency)
declare module "@electron/remote" {
export interface BrowserWindow {
new (options?: Record<string, unknown>): {
show(): void;
loadURL(url: string): Promise<void>;
destroy(): void;
webContents: {
session: {
cookies: {
get(filter: { domain: string }): Promise<Array<{
name: string;
value: string;
}>>;
flushStore(): Promise<void>;
};
};
};
};
}
export const BrowserWindow: BrowserWindow;
}

33
src/types/hh-mm-ss.d.ts vendored Normal file
View file

@ -0,0 +1,33 @@
declare module 'hh-mm-ss' {
/**
* Converts time in format HH:MM:SS to milliseconds
* @param time Time string in format HH:MM:SS
* @param format Format string (default: 'mm:ss')
* @returns Number of milliseconds
*/
export function toMs(time: string, format?: string): number;
/**
* Converts time in format HH:MM:SS to seconds
* @param time Time string in format HH:MM:SS
* @param format Format string (default: 'mm:ss')
* @returns Number of seconds
*/
export function toS(time: string, format?: string): number;
/**
* Converts milliseconds to time string in format HH:MM:SS
* @param ms Number of milliseconds
* @param format Format string (default: 'mm:ss')
* @returns Formatted time string
*/
export function fromMs(ms: number, format?: string): string;
/**
* Converts seconds to time string in format HH:MM:SS
* @param s Number of seconds
* @param format Format string (default: 'mm:ss')
* @returns Formatted time string
*/
export function fromS(s: number, format?: string): string;
}

56
src/types/langchain.d.ts vendored Normal file
View file

@ -0,0 +1,56 @@
declare module '@langchain/core/prompts' {
export class ChatPromptTemplate {
static fromMessages(messages: unknown[]): ChatPromptTemplate;
formatMessages(values: Record<string, unknown>): unknown[];
}
export class MessagesPlaceholder {
constructor(variableName: string);
}
}
declare module '@langchain/core/runnables' {
export class RunnableSequence {
static from(runnables: unknown[]): RunnableSequence;
invoke(input: unknown): Promise<unknown>;
}
}
declare module '@langchain/openai' {
import { BaseMessageLike } from '@langchain/core/messages';
export class ChatOpenAI {
constructor(config: {
modelName?: string;
temperature?: number;
maxTokens?: number;
apiKey?: string;
});
invoke(messages: BaseMessageLike[]): Promise<unknown>;
}
}
declare module '@langchain/anthropic' {
import { BaseMessageLike } from '@langchain/core/messages';
export class ChatAnthropic {
constructor(config: {
modelName?: string;
temperature?: number;
maxTokens?: number;
apiKey?: string;
});
invoke(messages: BaseMessageLike[]): Promise<unknown>;
}
}
declare module '@langchain/google-genai' {
import { BaseMessageLike } from '@langchain/core/messages';
export class ChatGoogleGenerativeAI {
constructor(config: {
modelName?: string;
temperature?: number;
maxTokens?: number;
apiKey?: string;
});
invoke(messages: BaseMessageLike[]): Promise<unknown>;
}
}

22
src/types/youtube-transcript-api.d.ts vendored Normal file
View file

@ -0,0 +1,22 @@
declare module 'youtube-transcript-api' {
// Define the structure for a transcript segment from the API
interface TranscriptItem {
text: string;
start: number;
duration: number;
// Allow for additional properties that might come from the API
[key: string]: unknown;
}
interface TranscriptOptions {
lang?: string;
country?: string;
// Any other options the API might accept
[key: string]: unknown;
}
// Using the class name we found in the actual code
export default class TranscriptAPI {
static getTranscript(videoId: string, options?: TranscriptOptions): Promise<TranscriptItem[]>;
}
}

149
src/utils/error-utils.ts Normal file
View file

@ -0,0 +1,149 @@
/**
* Utilities for standardized error handling across the plugin
*/
/**
* Safely extracts error message from any type of error object
* @param error The error object
* @param defaultMessage Default message if extraction fails
* @returns A safe error message string
*/
export function getSafeErrorMessage(error: unknown, defaultMessage = 'Unknown error occurred'): string {
if (error instanceof Error && error.message) {
return error.message;
}
try {
return String(error) || defaultMessage;
} catch {
return defaultMessage;
}
}
/**
* Categories of common errors
*/
export enum ErrorCategory {
Network = 'network',
ApiKey = 'api_key',
RateLimit = 'rate_limit',
TokenLimit = 'token_limit',
CORS = 'cors',
Timeout = 'timeout',
NotFound = 'not_found',
Unknown = 'unknown'
}
/**
* Detects error category based on error message content
* @param error The error object
* @returns The detected error category
*/
export function detectErrorCategory(error: unknown): ErrorCategory {
const message = getSafeErrorMessage(error);
// Network errors
if (message.includes('network') ||
message.includes('fetch') ||
message.includes('connect') ||
message.includes('ECONNREFUSED') ||
message.includes('NetworkError') ||
message.includes('Failed to fetch')) {
return ErrorCategory.Network;
}
// CORS errors
if (message.includes('CORS') ||
message.includes('Cross-Origin') ||
message.includes('Access-Control-Allow-Origin')) {
return ErrorCategory.CORS;
}
// API key errors
if (message.includes('API key') ||
message.includes('authentication') ||
message.includes('auth') ||
message.includes('unauthorized')) {
return ErrorCategory.ApiKey;
}
// Rate limit errors
if (message.includes('rate limit') ||
message.includes('quota') ||
message.includes('too many requests')) {
return ErrorCategory.RateLimit;
}
// Token limit errors
if (message.includes('context length') ||
message.includes('token limit') ||
message.includes('max_tokens')) {
return ErrorCategory.TokenLimit;
}
// Timeout errors
if (message.includes('timeout') ||
message.includes('timed out')) {
return ErrorCategory.Timeout;
}
// Not found errors
if (message.includes('not found') ||
message.includes('404')) {
return ErrorCategory.NotFound;
}
return ErrorCategory.Unknown;
}
/**
* Creates a friendly error message for a specific API
* @param error The original error
* @param apiName Name of the API to prefix error with
* @returns Formatted error with appropriate message
*/
export function createApiError(error: unknown, apiName: string): Error {
const category = detectErrorCategory(error);
const originalMessage = getSafeErrorMessage(error);
switch (category) {
case ErrorCategory.Network:
return new Error(`[${apiName}] Network error while connecting to service. Please check your internet connection.`);
case ErrorCategory.CORS:
return new Error(`[${apiName}] CORS policy blocked the request. Please check your connection or try a different request.`);
case ErrorCategory.ApiKey:
return new Error(`[${apiName}] Invalid API key or authentication error. Please check your settings.`);
case ErrorCategory.RateLimit:
return new Error(`[${apiName}] Rate limit reached or quota exceeded. Please try again later.`);
case ErrorCategory.TokenLimit:
return new Error(`[${apiName}] Input too long for model's context window.`);
case ErrorCategory.Timeout:
return new Error(`[${apiName}] Request timed out. Please try again.`);
case ErrorCategory.NotFound:
return new Error(`[${apiName}] Resource not found. Please check the request parameters.`);
default:
// For unknown errors, append the original message
return new Error(`[${apiName}] ${originalMessage}`);
}
}
/**
* Logs an error with standardized formatting and returns a user-friendly error
* @param error The original error
* @param apiName Name of the API to prefix error with
* @param context Additional context for debugging
* @returns Formatted error with appropriate message
*/
export function handleApiError(error: unknown, apiName: string, context?: string): Error {
// Log detailed error for debugging
console.error(`[${apiName}]${context ? ' [' + context + ']' : ''} Error:`, error);
// Return a user-friendly error
return createApiError(error, apiName);
}

273
src/utils/fetch-shim.ts Normal file
View file

@ -0,0 +1,273 @@
import { requestUrl } from "obsidian";
import { getSafeErrorMessage } from "./error-utils";
import { getLogger } from "./logger";
// Get logger for fetch shim
const logger = getLogger('FETCH');
type UnknownRecord = Record<string, unknown>;
const isRecord = (value: unknown): value is UnknownRecord =>
typeof value === 'object' && value !== null;
// Define the response type based on what requestUrl actually returns
interface RequestUrlResponse {
status: number;
text: string;
json?: unknown;
headers: Record<string, string>;
arrayBuffer?: ArrayBuffer;
}
interface RequestUrlOptions {
url: string;
method?: string;
headers: Record<string, string>;
body?: string;
throw?: boolean;
arrayBuffer?: ArrayBuffer | ArrayBufferLike;
}
const SENSITIVE_HEADER_KEYS = new Set([
'authorization',
'x-api-key',
'x-goog-api-key',
'api-key',
'x-anthropic-api-key',
'cookie'
]);
const SENSITIVE_QUERY_KEYS = new Set(['key', 'api_key', 'access_token', 'token']);
const redactUrlForLog = (rawUrl: string): string => {
try {
const parsed = new URL(rawUrl);
let mutated = false;
for (const name of Array.from(parsed.searchParams.keys())) {
if (SENSITIVE_QUERY_KEYS.has(name.toLowerCase())) {
parsed.searchParams.set(name, '[REDACTED]');
mutated = true;
}
}
return mutated ? parsed.toString() : rawUrl;
} catch {
return rawUrl;
}
};
const redactHeadersForLog = (headers: Record<string, string>): Record<string, string> => {
const redacted: Record<string, string> = {};
for (const [key, value] of Object.entries(headers)) {
redacted[key] = SENSITIVE_HEADER_KEYS.has(key.toLowerCase()) ? '[REDACTED]' : value;
}
return redacted;
};
const normalizeHeaders = (headers?: HeadersInit): Record<string, string> => {
if (!headers) {
return {};
}
if (headers instanceof Headers) {
const result: Record<string, string> = {};
headers.forEach((value, key) => {
result[key] = value;
});
return result;
}
if (Array.isArray(headers)) {
return Object.fromEntries(headers.map(([key, value]) => [key, String(value)]));
}
return Object.entries(headers).reduce<Record<string, string>>((acc, [key, value]) => {
acc[key] = String(value);
return acc;
}, {});
};
/**
* A fetch API implementation that uses Obsidian's requestUrl method.
* This allows us to make HTTP requests that work on both desktop and mobile.
*
* @param input URL or Request object
* @param init Request options
* @returns A standard Response object
*/
export async function obsidianFetch(input: RequestInfo, init?: RequestInit): Promise<Response> {
try {
// Extract URL using a more robust approach
let url: string;
if (typeof input === "string") {
url = input;
} else if (input instanceof URL) {
url = input.toString();
} else if (input && typeof input === "object" && 'url' in input) {
// Handle Request objects
url = String(input.url);
} else {
// Final fallback
url = String(input);
}
// Log the URL for debugging (redact credentials in query string)
logger.debug(`Fetching URL: ${redactUrlForLog(url)}`);
// Sanitize and validate the URL
try {
// This will throw if the URL is invalid
const parsedUrl = new URL(url);
// Ensure protocol is http or https
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
throw new Error(`Invalid protocol: ${parsedUrl.protocol}`);
}
// Use the validated and normalized URL
url = parsedUrl.toString();
} catch (e) {
logger.error(`Invalid URL: ${url}`, e);
throw new TypeError(`Invalid URL: ${url}`);
}
// Convert fetch API options to requestUrl format
const options: RequestUrlOptions = {
url: url,
method: init?.method ?? "GET",
headers: normalizeHeaders(init?.headers),
throw: false, // Handle errors manually for better mapping to fetch API
};
// Handle different body types
if (init?.body) {
if (typeof init.body === "string") {
options.body = init.body;
} else if (init.body instanceof FormData) {
// Handle FormData
const formData = init.body;
const boundary = `----FormBoundary${Math.random().toString(36).substring(2)}`;
let formBody = '';
formData.forEach((value, key) => {
formBody += `--${boundary}\r\n`;
formBody += `Content-Disposition: form-data; name="${key}"\r\n\r\n`;
const valueString = typeof value === "string"
? value
: value instanceof File
? value.name ?? "[file]"
: "[binary]";
formBody += `${valueString}\r\n`;
});
formBody += `--${boundary}--\r\n`;
options.body = formBody;
options.headers = {
...options.headers,
'Content-Type': `multipart/form-data; boundary=${boundary}`
};
} else if (init.body instanceof ArrayBuffer || ArrayBuffer.isView(init.body)) {
// Handle binary data
options.arrayBuffer = init.body instanceof ArrayBuffer
? init.body
: (init.body as ArrayBufferView).buffer;
} else {
// For objects, try to stringify to JSON
try {
options.body = JSON.stringify(init.body);
// Set Content-Type if not already set
if (!options.headers['Content-Type']) {
options.headers['Content-Type'] = 'application/json';
}
} catch (e) {
logger.error("Could not stringify request body:", e);
}
}
}
// Execute the request (redact credentials before logging)
logger.debug(`Sending request with options: ${JSON.stringify({
url: redactUrlForLog(options.url),
method: options.method,
headers: redactHeadersForLog(options.headers)
})}`);
let res: RequestUrlResponse;
try {
logger.debug('About to call requestUrl...');
logger.debug(`URL length: ${options.url.length} characters`);
logger.debug(`URL starts with: ${redactUrlForLog(options.url).substring(0, 100)}...`);
res = await requestUrl(options) as unknown as RequestUrlResponse;
logger.debug(`requestUrl completed - Status: ${res?.status}, HasText: ${!!res?.text}`);
} catch (requestError) {
const requestErrorMessage = getSafeErrorMessage(requestError);
const requestErrorRecord = isRecord(requestError) ? requestError : null;
logger.error('requestUrl threw error:', requestError);
logger.error('requestError type:', typeof requestError);
logger.error('requestError message:', requestErrorMessage);
logger.error('requestError status:', requestErrorRecord && typeof requestErrorRecord.status === 'number'
? requestErrorRecord.status
: 'No status');
logger.error('requestError instanceof Error:', requestError instanceof Error);
logger.error('requestError name:', requestError instanceof Error ? requestError.name : 'Unknown');
throw requestError; // Re-throw to be handled by outer catch
}
// Create a proper Response object
const responseInit: ResponseInit = {
status: res.status,
statusText: res.status.toString(),
headers: new Headers(res.headers || {})
};
// Handle different response types
if (res.arrayBuffer) {
return new Response(res.arrayBuffer, responseInit);
} else {
return new Response(res.text, responseInit);
}
} catch (error) {
// Enhanced error logging for debugging
logger.error("Fetch shim error - Raw error:", error);
logger.error("Error type:", typeof error);
logger.error("Error is null/undefined:", error == null);
if (error) {
const errorMessage = getSafeErrorMessage(error);
logger.error("Error message:", errorMessage || 'No message');
logger.error("Error stack:", error instanceof Error && error.stack ? error.stack : 'No stack');
try {
logger.error("Error toString:", String(error) || 'Cannot convert to string');
} catch {
logger.error("Error toString:", 'Cannot convert to string');
}
}
// Check if this is an HTTP error response from requestUrl
if (isRecord(error) && typeof error.status === 'number') {
// This is an HTTP error response, preserve the actual status code
const status = error.status;
const text = typeof error.text === 'string' ? error.text : '';
const headers = isRecord(error.headers) ? (error.headers as Record<string, string>) : {};
logger.error(`HTTP error response - Status: ${status}, Text: ${text.substring(0, 200)}`);
return new Response(text || JSON.stringify({ error: `HTTP ${status}` }), {
status,
statusText: status.toString(),
headers: new Headers(headers)
});
}
// Enhanced logging for other error types (skip full-object dump — may contain
// credentials echoed in request headers by some runtimes)
if (isRecord(error)) {
logger.error("Error properties:", Object.keys(error));
}
// For real network/connection errors, throw the original error
// This allows the calling code to handle 403/429/etc. properly
throw error;
}
}

View file

@ -0,0 +1,48 @@
/**
* Sanitizes a string to be used as a filename by:
* 1. Removing all non-alphanumeric characters (including emojis and special characters)
* 2. Replacing spaces with hyphens
* 3. Removing leading/trailing spaces and dots
* 4. Ensuring the filename isn't too long
* 5. Handling special cases for Obsidian
*/
export function sanitizeFilename(title: string): string {
if (!title || title.trim() === '') {
return 'untitled-note'; // Use a hyphenated default name
}
// First, normalize Unicode characters and remove anything non-alphanumeric
let sanitized = title
.normalize('NFD') // Decompose Unicode characters
.replace(/[\u0300-\u036f]/g, '') // Remove diacritics
.replace(/[^\p{L}\p{N}\s-]/gu, '') // Only allow letters, numbers, spaces and hyphens
.replace(/\s+/g, '-') // Replace spaces with hyphens
.trim() // Remove leading/trailing spaces
.replace(/^\.+|-+\.+$/g, '') // Remove leading/trailing dots and hyphens
.replace(/^\.*$/g, 'untitled') // Replace dot-only filenames
.replace(/^\/*$/g, 'untitled') // Replace slash-only filenames
.replace(/-+/g, '-'); // Replace multiple hyphens with a single hyphen
// Remove trailing hyphens, periods, underscores and other common separators
sanitized = sanitized.replace(/[-_.]+$/g, '');
// Ensure the filename isn't too long (max 255 characters)
if (sanitized.length > 255) {
sanitized = sanitized.substring(0, 252) + '...';
}
// If the sanitized string is empty after all processing, use a default name
if (!sanitized || /^\s*$/.test(sanitized)) {
sanitized = 'untitled-note';
}
// Ensure the filename doesn't start with a number (Obsidian requirement)
if (/^\d/.test(sanitized)) {
sanitized = 'note-' + sanitized;
}
// Final check for trailing separators that might have been added in other steps
sanitized = sanitized.replace(/[-_.]+$/g, '');
return sanitized;
}

255
src/utils/form-utils.ts Normal file
View file

@ -0,0 +1,255 @@
import { Notice } from 'obsidian';
/**
* Form validation and UI utilities to reduce duplication across modal classes
*/
/**
* Interface for validation results
*/
export interface ValidationResult {
isValid: boolean;
message?: string;
}
/**
* Options for displaying error messages
*/
export interface ErrorDisplayOptions {
element: HTMLElement;
styleClass?: string;
timeout?: number;
}
/**
* Validates that a required field has a value
*
* @param value The value to check
* @param fieldName The name of the field (for error message)
* @returns Validation result
*/
export function validateRequired(value: string, fieldName: string = 'field'): ValidationResult {
if (!value || value.trim() === '') {
return {
isValid: false,
message: `Please enter a ${fieldName}`
};
}
return { isValid: true };
}
/**
* Validates a value against a custom validation function
*
* @param value The value to validate
* @param validationFn The validation function
* @param errorMessage Message to show if validation fails
* @returns Validation result
*/
export function validateCustom(
value: string,
validationFn: (value: string) => boolean,
errorMessage: string
): ValidationResult {
if (!validationFn(value)) {
return {
isValid: false,
message: errorMessage
};
}
return { isValid: true };
}
/**
* Shows an error message in the specified element
*
* @param result The validation result
* @param options Options for displaying the error
* @returns Whether the validation passed
*/
export function displayValidationResult(
result: ValidationResult,
options: ErrorDisplayOptions
): boolean {
const { element, styleClass = 'error', timeout } = options;
if (!result.isValid) {
// Set error message, with a fallback if none provided
const message = result.message || 'An error occurred';
element.setText(message);
// Show the error element by adding visible class and removing hidden class
element.addClass('tubesage-error-visible');
element.removeClass('tubesage-error-hidden');
// Apply style class if provided, ensuring it's namespaced
if (styleClass) {
// If styleClass already has tubesage- prefix, use it as is
// Otherwise add the prefix to ensure proper namespacing
const namespacedClass = styleClass.startsWith('tubesage-')
? styleClass
: `tubesage-${styleClass}`;
element.addClass(namespacedClass);
}
// Auto-hide after timeout if provided
if (timeout) {
activeWindow.setTimeout(() => {
element.addClass('tubesage-error-hidden');
element.removeClass('tubesage-error-visible');
}, timeout);
}
return false;
} else {
// Hide error element if validation passed
element.addClass('tubesage-error-hidden');
element.removeClass('tubesage-error-visible');
return true;
}
}
/**
* Validates an input field and displays error if invalid
*
* @param inputEl The input element to validate
* @param errorEl The error display element
* @param validationFn The validation function
* @returns Whether validation passed
*/
export function validateInputField(
value: string,
errorEl: HTMLElement,
validations: ValidationResult[]
): boolean {
// Check all validations
for (const validation of validations) {
if (!validation.isValid) {
// Explicitly use the namespaced error class for consistency
return displayValidationResult(validation, {
element: errorEl,
styleClass: 'tubesage-error'
});
}
}
// All validations passed
errorEl.addClass('tubesage-error-hidden');
errorEl.removeClass('tubesage-error-visible');
return true;
}
/**
* Validate a YouTube URL (utility wrapper)
*
* @param url The URL to validate
* @param isYoutubeUrlFn The YouTube URL validation function
* @returns Validation result
*/
export function validateYouTubeUrl(
url: string,
isYoutubeUrlFn: (url: string) => boolean
): ValidationResult {
if (!url) {
return {
isValid: false,
message: 'Please enter a YouTube URL'
};
}
if (!isYoutubeUrlFn(url)) {
return {
isValid: false,
message: 'Please enter a valid YouTube video, playlist, or channel URL'
};
}
return { isValid: true };
}
/**
* Shows a notification with a timeout
*
* @param message The message to display
* @param timeout Duration to show the notice (ms)
*/
export function showNotice(message: string, timeout: number = 5000): void {
new Notice(message, timeout);
}
/**
* Filter a list of items by a search term
*
* @param items List of items to filter
* @param searchTerm Search term to filter by
* @param getSearchableText Function to extract searchable text from an item
* @returns Filtered list of items
*/
export function filterItems<T>(
items: T[],
searchTerm: string,
getSearchableText: (item: T) => string
): T[] {
if (!searchTerm || searchTerm.trim() === '') {
return items; // Return all items if no search term
}
const lowerSearchTerm = searchTerm.toLowerCase().trim();
return items.filter(item => {
const text = getSearchableText(item).toLowerCase();
return text.includes(lowerSearchTerm);
});
}
/**
* Creates a key handler for navigation in a list with arrow keys
*
* @param getItems Function to get all items
* @param selectItem Function to select an item
* @param onEnter Function to handle Enter key press
* @returns KeyboardEvent handler function
*/
export function createListKeyHandler(
getItems: () => HTMLElement[],
selectItem: (item: HTMLElement) => void,
onEnter: () => void,
onEscape?: () => void
): (e: KeyboardEvent) => void {
return (e: KeyboardEvent) => {
if (e.key === 'Escape' && onEscape) {
e.preventDefault();
onEscape();
return;
}
if (e.key === 'Enter') {
e.preventDefault();
onEnter();
return;
}
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
const items = getItems();
if (items.length === 0) return;
// Find currently selected item
const selectedItem = items.find(item => item.classList.contains('selected'));
const currentIndex = selectedItem ? items.indexOf(selectedItem) : -1;
let newIndex: number;
if (e.key === 'ArrowDown') {
newIndex = currentIndex < items.length - 1 ? currentIndex + 1 : 0;
} else {
newIndex = currentIndex > 0 ? currentIndex - 1 : items.length - 1;
}
selectItem(items[newIndex]);
items[newIndex].scrollIntoView({ block: 'nearest' });
}
};
}

271
src/utils/logger.ts Normal file
View file

@ -0,0 +1,271 @@
/**
* Centralized logging utility for the YouTube Transcript plugin
*/
/**
* Log levels
*/
export enum LogLevel {
DEBUG = 0,
INFO = 1,
WARN = 2,
ERROR = 3,
NONE = 4 // Used to disable all logging
}
/**
* Logger configuration
*/
interface LoggerConfig {
/** Global minimum log level */
globalLogLevel: LogLevel;
/** Category-specific log levels */
categoryLevels: Record<string, LogLevel>;
/** Whether to include timestamps in logs */
includeTimestamp: boolean;
/** Whether to prefix messages with category and level */
includePrefix: boolean;
}
// Structure for log entries
interface LogEntry {
timestamp: Date;
level: LogLevel;
category: string;
message: string;
args: unknown[];
formattedMessage: string;
}
// Default configuration
const defaultConfig: LoggerConfig = {
globalLogLevel: LogLevel.INFO,
categoryLevels: {},
includeTimestamp: false,
includePrefix: true
};
// Current configuration
let config: LoggerConfig = { ...defaultConfig };
// Central buffer to store all log messages
const logBuffer: LogEntry[] = [];
/**
* Logger class for a specific category
*/
export class Logger {
/**
* Creates a new logger for a specific category
* @param category The logging category (e.g., 'PROXY', 'LLM', 'UI')
*/
constructor(private category: string) {}
/**
* Logs a debug message
* @param message Message or object to log
* @param args Additional arguments to log
*/
debug(message: unknown, ...args: unknown[]): void {
this.log(LogLevel.DEBUG, message, ...args);
}
/**
* Logs an info message
* @param message Message or object to log
* @param args Additional arguments to log
*/
info(message: unknown, ...args: unknown[]): void {
this.log(LogLevel.INFO, message, ...args);
}
/**
* Logs a warning message
* @param message Message or object to log
* @param args Additional arguments to log
*/
warn(message: unknown, ...args: unknown[]): void {
this.log(LogLevel.WARN, message, ...args);
}
/**
* Logs an error message
* @param message Message or object to log
* @param args Additional arguments to log
*/
error(message: unknown, ...args: unknown[]): void {
this.log(LogLevel.ERROR, message, ...args);
}
/**
* Internal logging method
* @param level Log level
* @param message Message to log
* @param args Additional arguments to log
*/
private log(level: LogLevel, message: unknown, ...args: unknown[]): void {
// Check if we should log this message based on level
if (!this.shouldLog(level)) {
return;
}
// Format message with timestamp and category if configured
let formattedMessage = typeof message === 'string' ? message : String(message);
if (config.includePrefix) {
const levelName = LogLevel[level];
formattedMessage = `[${this.category}] [${levelName}] ${formattedMessage}`;
}
// Create a log entry
const timestamp = new Date();
let displayMessage = formattedMessage;
const messageText = typeof message === 'string' ? message : String(message);
if (config.includeTimestamp) {
displayMessage = `${timestamp.toISOString()} ${formattedMessage}`;
}
// Add to log buffer instead of console
logBuffer.push({
timestamp,
level,
category: this.category,
message: messageText,
args,
formattedMessage: displayMessage
});
}
/**
* Determines if a message should be logged based on configured levels
* @param level The message log level
* @returns Whether the message should be logged
*/
private shouldLog(level: LogLevel): boolean {
// Check if category has a specific level
const categoryLevel = config.categoryLevels[this.category];
if (categoryLevel !== undefined) {
return level >= categoryLevel;
}
// Otherwise use global level
return level >= config.globalLogLevel;
}
}
/**
* Updates logger configuration
* @param newConfig Configuration to apply
*/
export function configureLogger(newConfig: Partial<LoggerConfig>): void {
config = { ...config, ...newConfig };
}
/**
* Sets the global minimum log level
* @param level Minimum log level to display
*/
export function setGlobalLogLevel(level: LogLevel): void {
config.globalLogLevel = level;
}
/**
* Sets a category-specific log level
* @param category Category name
* @param level Minimum log level to display for this category
*/
export function setCategoryLogLevel(category: string, level: LogLevel): void {
config.categoryLevels[category] = level;
}
/**
* Creates or retrieves a logger for a specific category
* @param category Category name
* @returns Logger instance for the category
*/
export function getLogger(category: string): Logger {
return new Logger(category);
}
/**
* Retrieves all log entries as formatted strings
* @returns Array of formatted log messages
*/
export function getLogEntries(): string[] {
return logBuffer.map(entry => entry.formattedMessage);
}
/**
* Gets all log entries as a single string
* @param separator Line separator (default: newline)
* @returns Concatenated log messages
*/
export function getLogsAsString(separator: string = '\n'): string {
return logBuffer.map(entry => entry.formattedMessage).join(separator);
}
/**
* Gets all log entries formatted for Obsidian callouts
* Each log entry is properly formatted to maintain callout structure
* @returns Formatted log messages for callout display
*/
export function getLogsForCallout(): string {
const MAX_LINE_LENGTH = 120; // Prevent very long lines from breaking callouts
return logBuffer.map(entry => {
// Each log entry might contain multiple lines, so we need to add ">" prefix to each line
const lines = entry.formattedMessage.split('\n');
return lines.map(line => {
// Clean the line to prevent callout breaking (keep URLs as-is for debugging)
let cleanedLine = line
.replace(/\r/g, '') // Remove carriage returns
.replace(/\t/g, ' ') // Convert tabs to spaces
.replace(/[^\x20-\x7E\n]/g, ''); // Remove non-printable chars except newlines
// Note: Removed HTML entity escaping to show actual URLs in logs
// Wrap very long lines to prevent callout breaking
if (cleanedLine.length > MAX_LINE_LENGTH) {
const wrappedLines = [];
while (cleanedLine.length > MAX_LINE_LENGTH) {
// Find a good break point (space, comma, etc.)
let breakPoint = MAX_LINE_LENGTH;
const goodBreaks = [' ', ',', '&', '=', '?'];
for (let i = MAX_LINE_LENGTH - 1; i > MAX_LINE_LENGTH - 20 && i > 0; i--) {
if (goodBreaks.includes(cleanedLine[i])) {
breakPoint = i + 1;
break;
}
}
wrappedLines.push('> ' + cleanedLine.substring(0, breakPoint));
cleanedLine = ' ' + cleanedLine.substring(breakPoint); // Indent continuation
}
if (cleanedLine.trim()) {
wrappedLines.push('> ' + cleanedLine);
}
return wrappedLines.join('\n');
} else {
return '> ' + cleanedLine;
}
}).join('\n');
}).join('\n');
}
/**
* Clears the log buffer
*/
export function clearLogs(): void {
logBuffer.length = 0;
}
// For backward compatibility, also export some common loggers directly
export const pluginLogger = getLogger('PLUGIN');
export const llmLogger = getLogger('LLM');
export const proxyLogger = getLogger('PROXY');
export const transcriptLogger = getLogger('TRANSCRIPT');
export const uiLogger = getLogger('UI');

View file

@ -0,0 +1,240 @@
// model-limits-registry.ts
export type Provider = "openai" | "anthropic" | "google" | "ollama";
export type ModelLimits = {
context: number; // total context window (input+output)
maxOutput: number; // vendor max output cap
inputMax?: number; // optional explicit vendor cap on input (if published)
reserveOutputPct?: number; // 0.10 for 10%, 0.15 for 15%, etc.
};
export type EffectiveLimits = ModelLimits & {
maxOutputEff: number; // floor(maxOutput * (1 - reserveOutputPct))
inputMaxEff: number; // context - maxOutputEff (unless inputMax provided; we take min)
};
type Registry = Record<Provider, Record<string, ModelLimits>>;
// ---------------------------
// Enhanced model registry with massive context windows
// ---------------------------
export const BASE_MODELS: Registry = {
openai: {
// GPT-5 series - massive context windows
"gpt-5": {
context: 400_000,
maxOutput: 128_000,
reserveOutputPct: 0.10
},
// GPT-4o series
"gpt-4o": {
context: 128_000,
maxOutput: 16_384,
reserveOutputPct: 0.10
},
"gpt-4o-mini": {
context: 128_000,
maxOutput: 16_384,
reserveOutputPct: 0.10
},
// Legacy models (maintain backward compatibility)
"gpt-4-turbo": {
context: 128_000,
maxOutput: 4_096,
reserveOutputPct: 0.10
},
"gpt-4": {
context: 8_192,
maxOutput: 4_096,
reserveOutputPct: 0.10
},
"gpt-3.5-turbo": {
context: 16_384,
maxOutput: 4_096,
reserveOutputPct: 0.10
}
},
anthropic: {
// Claude 4 series - new models
"claude-opus-4-0": {
context: 400_000,
maxOutput: 32_000,
reserveOutputPct: 0.10
},
"claude-opus-4-1": {
context: 400_000,
maxOutput: 32_000,
reserveOutputPct: 0.10
},
"claude-sonnet-4-0": {
context: 400_000,
maxOutput: 16_000,
reserveOutputPct: 0.10
},
// Claude 3.5 series
"claude-3-5-sonnet-20241022": {
context: 200_000,
maxOutput: 8_192,
reserveOutputPct: 0.10
},
"claude-3-5-haiku-20241022": {
context: 200_000,
maxOutput: 8_192,
reserveOutputPct: 0.10
},
// Legacy Claude 3
"claude-3-sonnet-20240229": {
context: 200_000,
maxOutput: 4_096,
reserveOutputPct: 0.10
},
"claude-3-opus-20240229": {
context: 200_000,
maxOutput: 4_096,
reserveOutputPct: 0.10
},
"claude-3-haiku-20240307": {
context: 200_000,
maxOutput: 4_096,
reserveOutputPct: 0.10
}
},
google: {
// Gemini 2.5 series - new model
"gemini-2.5-flash": {
context: 2_000_000,
maxOutput: 16_384,
reserveOutputPct: 0.10
},
// Gemini 2.0 series
"gemini-2.0-flash-exp": {
context: 1_000_000,
maxOutput: 8_192,
reserveOutputPct: 0.10
},
// Gemini 1.5 series
"gemini-1.5-pro": {
context: 2_000_000,
maxOutput: 8_192,
reserveOutputPct: 0.10
},
"gemini-1.5-flash": {
context: 1_000_000,
maxOutput: 8_192,
reserveOutputPct: 0.10
},
"gemini-1.5-flash-8b": {
context: 1_000_000,
maxOutput: 8_192,
reserveOutputPct: 0.10
}
},
ollama: {
// Local models - higher reserve due to local constraints
"llama3.1": {
context: 32_768,
maxOutput: 8_192,
reserveOutputPct: 0.15
},
"llama3.1:70b": {
context: 32_768,
maxOutput: 8_192,
reserveOutputPct: 0.15
},
"llama3.1:8b": {
context: 32_768,
maxOutput: 4_096,
reserveOutputPct: 0.15
},
"qwen2.5": {
context: 32_768,
maxOutput: 8_192,
reserveOutputPct: 0.15
},
"mistral": {
context: 32_768,
maxOutput: 4_096,
reserveOutputPct: 0.15
}
}
};
// ---------------------------
// Mutable registry (base + future custom models)
// ---------------------------
const registry: Registry = structuredClone(BASE_MODELS);
// Add or override a model under a provider (for future custom model support)
export function upsertModel(
provider: Provider,
modelId: string,
limits: ModelLimits
) {
registry[provider] ??= {};
registry[provider][modelId] = limits;
}
// Read raw limits (throws if missing)
export function getRawLimits(provider: Provider, modelId: string): ModelLimits {
const prov = registry[provider];
if (!prov || !prov[modelId]) {
throw new Error(`No limits registered for ${provider}:${modelId}`);
}
return prov[modelId];
}
// Compute effective limits (apply reserve pct and derive inputMaxEff)
export function getEffectiveLimits(provider: Provider, modelId: string): EffectiveLimits {
const raw = getRawLimits(provider, modelId);
const reserve = raw.reserveOutputPct ?? 0; // default: no reserve unless specified
const maxOutputEff = Math.max(0, Math.floor(raw.maxOutput * (1 - reserve)));
// If vendor publishes an inputMax, respect it; otherwise derive from context
const derivedInputMax = Math.max(0, raw.context - maxOutputEff);
const inputMaxEff = raw.inputMax != null
? Math.min(raw.inputMax, derivedInputMax)
: derivedInputMax;
return { ...raw, maxOutputEff, inputMaxEff };
}
// Get provider from model string (helper for dynamic detection)
export function getProviderFromModel(modelId: string): Provider | null {
for (const [provider, models] of Object.entries(registry)) {
if (models[modelId]) {
return provider as Provider;
}
}
return null;
}
// Check if model is supported
export function isModelSupported(provider: Provider, modelId: string): boolean {
return !!(registry[provider]?.[modelId]);
}
// Get all models for a provider
export function getModelsForProvider(provider: Provider): string[] {
return Object.keys(registry[provider] || {});
}
// Legacy compatibility - get old-style max tokens limit for backwards compatibility
export function getLegacyMaxTokens(provider: Provider, modelId: string): number {
try {
const limits = getEffectiveLimits(provider, modelId);
return limits.maxOutputEff;
} catch {
// Fallback to legacy hardcoded limits if model not in registry
const LEGACY_LIMITS: Record<string, number> = {
'openai': 4096,
'anthropic': 4096,
'google': 8192,
'ollama': 4096,
'default': 4096
};
return LEGACY_LIMITS[provider] || LEGACY_LIMITS['default'];
}
}

110
src/utils/path-utils.ts Normal file
View file

@ -0,0 +1,110 @@
import type { Vault } from 'obsidian';
/**
* Standardized path utilities for consistent folder and path management
*/
/**
* Normalizes a path by:
* 1. Trimming whitespace
* 2. Optionally removing leading slash (default: true)
* 3. Removing trailing slash
*
* @param path The path to normalize
* @param removeLeadingSlash Whether to remove leading slash (default: true)
* @returns Normalized path
*/
export function normalizePath(path: string, removeLeadingSlash: boolean = true): string {
if (!path) return '';
let normalized = path.trim();
// Remove leading slash if specified
if (removeLeadingSlash && normalized.startsWith('/')) {
normalized = normalized.substring(1);
}
// Remove trailing slash
if (normalized.endsWith('/')) {
normalized = normalized.substring(0, normalized.length - 1);
}
return normalized;
}
/**
* Ensures a folder exists, creating it if necessary
* Gracefully handles "already exists" errors
*
* @param vault Obsidian vault instance via app.vault
* @param folderPath Path to ensure exists
* @returns Promise resolving when complete
*/
export async function ensureFolder(vault: Vault, folderPath: string): Promise<void> {
if (!folderPath || folderPath.trim() === '') return;
// Normalize the path first
const normalizedPath = normalizePath(folderPath);
try {
await vault.adapter.mkdir(normalizedPath);
} catch (error) {
// Only ignore "already exists" errors
if (!(error instanceof Error) || !error.message.includes("already exists")) {
throw error;
}
// Folder already exists, which is fine
}
}
/**
* Joins path segments together, ensuring proper normalization
*
* @param segments Path segments to join
* @returns Joined path
*/
export function joinPaths(...segments: string[]): string {
if (segments.length === 0) return '';
// Filter out empty segments
const filteredSegments = segments.filter(segment => segment != null && segment !== '');
if (filteredSegments.length === 0) return '';
// Join with / and normalize
return normalizePath(filteredSegments.join('/'));
}
/**
* Sanitizes a string for use in file/folder paths
* Removes characters that are problematic in file systems
*
* @param text Text to sanitize
* @returns Sanitized text
*/
export function sanitizePathComponent(text: string): string {
if (!text) return '';
return text
.replace(/[\\/:*?"<>|]/g, '-') // Replace problematic chars with dash
.replace(/\s+/g, '-') // Replace spaces with dash
.replace(/-+/g, '-') // Replace multiple dashes with single dash
.trim(); // Remove leading/trailing whitespace
}
/**
* Gets the parent folder path of a given path
*
* @param path The path to get the parent of
* @returns The parent path
*/
export function getParentFolder(path: string): string {
const normalized = normalizePath(path);
const lastSlashIndex = normalized.lastIndexOf('/');
if (lastSlashIndex === -1) {
return ''; // No parent folder
}
return normalized.substring(0, lastSlashIndex);
}

157
src/utils/prompt-utils.ts Normal file
View file

@ -0,0 +1,157 @@
import { getLogger } from './logger';
// Initialize logger
const llmLogger = getLogger('LLM');
/**
* Summary mode type definition
*/
export enum SummaryMode {
FAST = 'fast',
EXTENSIVE = 'extensive'
}
/**
* Interface for prompt configuration
*/
export interface PromptConfig {
systemPrompt: string;
userPrompt: string;
maxTokens: number;
temperature: number;
}
/**
* Interface for all available prompts and settings
*/
export interface PromptSettings {
// Fast summary prompts
systemPrompt: string;
userPrompt: string;
// Extensive summary prompts
extensiveSystemPrompt: string;
extensiveUserPrompt: string;
// Timestamp links prompts
timestampSystemPrompt: string;
timestampUserPrompt: string;
// General settings
maxTokens: number;
temperature: number;
}
/**
* Get the appropriate prompt configuration based on the summary mode
*
* @param settings Prompt settings from the plugin
* @param summaryMode The summary mode (fast or extensive)
* @returns Complete prompt configuration
*/
export function getPromptConfig(
settings: PromptSettings,
summaryMode: SummaryMode = SummaryMode.EXTENSIVE,
effectiveMaxTokens?: number
): PromptConfig {
// Log the selected mode
llmLogger.debug("Creating prompt config for mode:", summaryMode);
// Use provided effective max tokens or fall back to settings value
const maxTokens = effectiveMaxTokens ?? settings.maxTokens;
if (summaryMode === SummaryMode.FAST) {
// Fast summary mode - reduced tokens for quicker processing
const tokenReduction = 0.25; // Use 1/4 of tokens for fast mode
return {
systemPrompt: settings.systemPrompt,
userPrompt: settings.userPrompt,
maxTokens: Math.floor(maxTokens * tokenReduction),
temperature: settings.temperature
};
} else {
// Extensive summary mode (default) - full token limit
return {
systemPrompt: settings.extensiveSystemPrompt,
userPrompt: settings.extensiveUserPrompt,
maxTokens: maxTokens,
temperature: settings.temperature
};
}
}
/**
* Get a prompt configuration for timestamp linking
*
* @param settings Prompt settings from the plugin
* @param videoId YouTube video ID to include in the prompt
* @returns Prompt configuration for timestamp linking
*/
export function getTimestampLinkConfig(
settings: PromptSettings,
videoId: string,
effectiveMaxTokens?: number
): PromptConfig {
// For timestamp linking, we need a lower temperature for more deterministic output
const timestampTemperature = 0.2;
// Use specific prompts for timestamp linking with safety checks
const systemPrompt = settings.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.';
const userPrompt = (settings.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")
EXACTLY HOW TO DO THIS:
1. Identify ALL section headings in the document that follow the format: # number. text
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
- Add the appropriate TimeIndex marker based on this content analysis
Video ID: VIDEO_ID`).replace(/VIDEO_ID/g, videoId);
return {
systemPrompt: systemPrompt,
userPrompt: userPrompt,
maxTokens: effectiveMaxTokens ?? settings.maxTokens,
temperature: timestampTemperature
};
}
/**
* Cleans a transcript by removing timestamps and formatting
*
* @param transcript Raw transcript text
* @returns Cleaned transcript suitable for LLM processing
*/
export function cleanTranscript(transcript: string): string {
// Split by lines and clean each line
return transcript
.split('\n')
.map(line => {
// Remove YAML indentation and timestamps
return line.trim().replace(/^\s*\[\d{2}:\d{2}:\d{2}\]\s*/, '');
})
.join(' '); // Join with spaces to form a single text block
}

View file

@ -0,0 +1,359 @@
import { getLogger } from './logger';
import * as TimeFormat from 'hh-mm-ss';
// Initialize logger
const logger = getLogger('TIMESTAMP');
/**
* Interface for extracted document components
*/
export interface DocumentComponents {
frontmatter: string;
contentWithoutFrontmatter: string;
transcript: string;
}
/**
* Extract the frontmatter, content, and transcript from a document
*
* @param originalContent The full document content
* @returns The extracted components
*/
export function extractDocumentComponents(originalContent: string): DocumentComponents {
let frontmatter = "";
let contentWithoutFrontmatter = originalContent;
let transcript = "";
if (originalContent.startsWith("---")) {
const endFrontmatter = originalContent.indexOf("---", 3);
if (endFrontmatter > 0) {
// Extract complete frontmatter including the closing ---
frontmatter = originalContent.substring(0, endFrontmatter + 3);
// Content starts after frontmatter
contentWithoutFrontmatter = originalContent.substring(endFrontmatter + 3).trim();
// Extract transcript from frontmatter for context
const frontmatterContent = originalContent.substring(3, endFrontmatter).trim();
// Look for transcript property in frontmatter
// Extract everything after "transcript: |" until the closing --- or another YAML property
const transcriptMatch = frontmatterContent.match(/transcript:\s*\|\s*\n([\s\S]+?)(?:\n(?:\w+:)|$)/);
if (transcriptMatch && transcriptMatch[1]) {
transcript = transcriptMatch[1].trim();
logger.debug("Successfully extracted transcript from frontmatter");
} else {
logger.debug("Could not find transcript in frontmatter");
}
}
}
return {
frontmatter,
contentWithoutFrontmatter,
transcript
};
}
/**
* Reconstruct the document with frontmatter and enhanced content
*
* @param frontmatter The original frontmatter
* @param enhancedContent The LLM-enhanced content
* @returns The reconstructed document
*/
export function reconstructDocument(frontmatter: string, enhancedContent: string): string {
let enhancedNote = frontmatter + "\n" + enhancedContent;
// Remove any unwanted code blocks that might appear after frontmatter
if (enhancedNote.includes("---\n```") || enhancedNote.includes("---\r\n```")) {
logger.debug("Removing unwanted code blocks after frontmatter");
enhancedNote = enhancedNote.replace(/---[\r\n]+```/g, "---");
enhancedNote = enhancedNote.replace(/```[\r\n]+(?!`)/g, ""); // Remove closing backticks
}
return enhancedNote;
}
/**
* Validate enhanced content before updating the document
*
* @param enhancedContent The LLM-enhanced content
* @param originalContent The original content
* @param headings The extracted headings
* @param videoId The YouTube video ID
* @returns Whether the content is valid
*/
export function validateEnhancedContent(
enhancedContent: string,
originalContent: string,
headings: string[],
videoId: string
): boolean {
// Check if response has proper Markdown formatting
if (!enhancedContent.includes('#')) {
logger.error("LLM response doesn't contain any markdown headings");
return false;
}
// Validate that the modified content hasn't lost significant content
if (enhancedContent.length < originalContent.length * 0.9) {
logger.error("LLM output is suspiciously short, might have lost content");
return false;
}
// Verify the output has timestamp links in headings (after conversion from TimeIndex markers)
const timestampLinkPattern = new RegExp(`^#+\\s+.*?\\[Watch\\]\\(https://www\\.youtube\\.com/watch\\?v=${videoId}&t=\\d+\\)`, 'm');
const hasLinks = timestampLinkPattern.test(enhancedContent);
if (!hasLinks) {
logger.warn("No timestamp links found in headings in final output");
logger.debug("Enhanced content sample:", enhancedContent.substring(0, 500));
return false;
}
return true;
}
/**
* Find headings in content
*
* @param content The document content
* @returns Array of headings with their text and positions
*/
export function findContentHeadings(content: string): { text: string; position: number }[] {
const headings: { text: string; position: number }[] = [];
// Find all headings in the content that match our expected format
// This will match:
// - ### 1.1. Heading (section heading)
// - ## 1. Heading (subheading)
// - # 1. Heading (main heading)
// But not:
// - # Heading (without number)
// - ## Heading (without number)
// - # (horizontal rule)
const headingRegex = /^(#+\s+\d+(?:\.\d+)?\.?\s+.*?)$/gm;
let match;
while ((match = headingRegex.exec(content)) !== null) {
const headingText = match[0];
headings.push({
text: headingText,
position: match.index
});
}
logger.debug(`Found ${headings.length} headings in content: ${headings.map(h => h.text).join(', ')}`);
return headings;
}
/**
* Create optimized chunks from content based on headings
*
* @param content The document content
* @param maxTokenLimit Maximum token limit
* @returns Array of content chunks
*/
export function createOptimizedChunks(
content: string,
maxTokenLimit: number
): string[] {
const headings = findContentHeadings(content);
const chunks: string[] = [];
logger.debug(`Found ${headings.length} headings in content`);
if (headings.length === 0) {
// No headings found, process content as a single chunk
chunks.push(content);
return chunks;
}
// Check if there's content before the first heading (template header)
if (headings[0].position > 0) {
// Add the template header as its own chunk (won't be modified, just preserved)
const templateHeader = content.substring(0, headings[0].position);
chunks.push(templateHeader);
logger.debug("Added template header as separate chunk");
}
// Optimize chunking by combining multiple sections to reduce LLM calls
const maxChunkTokens = maxTokenLimit * 0.7; // Use 70% of model's max tokens
const avgTokensPerChar = 0.25; // Conservative estimate: ~4 chars per token
let currentChunk = "";
let currentHeadingCount = 0;
let processedHeadings = 0;
// Process each heading to create optimized chunks
for (let i = 0; i < headings.length; i++) {
const startPos = headings[i].position;
const endPos = i < headings.length - 1 ?
headings[i + 1].position :
content.length;
const section = content.substring(startPos, endPos);
const sectionTokens = section.length * avgTokensPerChar;
// If adding this section would exceed token limit, create a new chunk
if (currentChunk && (currentChunk.length * avgTokensPerChar + sectionTokens) > maxChunkTokens) {
chunks.push(currentChunk);
logger.debug(`Added optimized chunk with ${currentHeadingCount} headings (${processedHeadings + 1}-${processedHeadings + currentHeadingCount})`);
currentChunk = section;
currentHeadingCount = 1;
processedHeadings += currentHeadingCount;
} else {
// Add section to current chunk
currentChunk += section;
currentHeadingCount++;
}
}
// Add the final chunk if not empty
if (currentChunk) {
chunks.push(currentChunk);
processedHeadings += currentHeadingCount;
logger.debug(`Added final optimized chunk with ${currentHeadingCount} headings (${processedHeadings - currentHeadingCount + 1}-${processedHeadings})`);
}
// Verify all headings were processed
if (processedHeadings !== headings.length) {
logger.warn(`Warning: Processed ${processedHeadings} headings but found ${headings.length} total headings`);
}
return chunks;
}
/**
* Ensure a chunk ends with a newline
*
* @param chunk The content chunk
* @returns The chunk with a trailing newline
*/
export function ensureTrailingNewline(chunk: string): string {
return chunk.endsWith("\n") ? chunk : chunk + "\n";
}
/**
* Count timestamp links in enhanced content
*
* @param content The enhanced content
* @returns The number of headings with timestamp links
*/
export function countTimestampLinks(content: string): number {
const headingsWithLinks = content.match(/^#+\s+\d+(?:\.\d+)?\.?\s+[^\n]*\[Watch\]/gm);
return headingsWithLinks ? headingsWithLinks.length : 0;
}
/**
* Check if a chunk contains a proper section heading
*
* @param chunk The content chunk
* @returns Whether the chunk contains a proper section heading
*/
export function hasProperHeading(chunk: string): boolean {
return !!chunk.match(/^#+\s+\d+(?:\.\d+)?\.?\s+/m);
}
/**
* Check if a chunk has valid TimeIndex markers
*
* @param chunk The content chunk
* @param videoId The YouTube video ID (kept for compatibility but not used)
* @returns Whether the chunk has valid TimeIndex markers
*/
export function hasTimestampLinks(chunk: string, videoId: string): boolean {
const timeIndexPattern = /\[TimeIndex:\d+\]/;
const hasTimeIndex = timeIndexPattern.test(chunk);
if (!hasTimeIndex) {
// Log the first few hundred characters to see what's coming back
logger.debug(`No TimeIndex markers found in chunk. First 200 chars: ${chunk.substring(0, 200)}...`);
// Check if we have any headings with proper format
const headings = chunk.match(/^#+\s+\d+(?:\.\d+)?\.?\s+/gm);
if (headings) {
logger.debug(`Found ${headings.length} numbered headings but no TimeIndex markers`);
} else {
logger.debug(`No numbered headings found in chunk`);
}
// Also check for Watch URLs in headings (after conversion from TimeIndex)
const timestampLinkPattern = new RegExp(`^#+\\s+.*?\\[Watch\\]\\(https://www\\.youtube\\.com/watch\\?v=${videoId}&t=\\d+\\)`, 'm');
const hasWatchLinks = timestampLinkPattern.test(chunk);
if (hasWatchLinks) {
const links = chunk.match(/^#+\s+.*?\[Watch\]\([^)]+\)/gm);
logger.debug(`Found ${links ? links.length : 0} Watch links in headings (converted from TimeIndex)`);
return true;
}
} else {
const markers = chunk.match(/\[TimeIndex:\d+\]/g);
logger.debug(`Found ${markers ? markers.length : 0} TimeIndex markers in chunk`);
}
return hasTimeIndex;
}
/**
* Converts a timestamp in HH:MM:SS format to seconds
* @param timestamp The timestamp in HH:MM:SS format
* @returns Total seconds
*/
export function convertTimestampToSeconds(timestamp: string): number {
try {
// Use the library's toS function exactly as documented in npm
// TimeFormat.toS('02:00:00', 'hh:mm:ss') => 7200
return TimeFormat.toS(timestamp, 'hh:mm:ss');
} catch (error) {
logger.error(`Error converting timestamp ${timestamp} to seconds: ${error}`);
return 0;
}
}
/**
* Converts TimeIndex markers to Watch URLs ONLY in markdown headings
*
* @param content The content containing TimeIndex markers
* @param videoId The YouTube video ID
* @returns Content with TimeIndex markers in headings replaced with Watch URLs
*/
export function convertTimeIndexToWatchUrls(content: string, videoId: string): string {
logger.debug(`Converting TimeIndex markers to Watch URLs for video: ${videoId} (headings only)`);
// Split content into lines for processing
const lines = content.split('\n');
let convertedCount = 0;
// Process each line, only converting TimeIndex markers in markdown headings
const processedLines = lines.map(line => {
// Check if this line is a markdown heading (starts with #)
const headingMatch = line.match(/^(#+\s+.*?)(\[TimeIndex:(\d+)\])(.*?)$/);
if (headingMatch) {
const headingStart = headingMatch[1]; // "## 1. Topic Title "
const timeIndexMarker = headingMatch[2]; // "[TimeIndex:123]"
const seconds = headingMatch[3]; // "123"
const headingEnd = headingMatch[4]; // any text after TimeIndex marker
const watchUrl = `[Watch](https://www.youtube.com/watch?v=${videoId}&t=${seconds})`;
convertedCount++;
logger.debug(`Converting heading TimeIndex: ${timeIndexMarker} to ${watchUrl}`);
return `${headingStart}${watchUrl}${headingEnd}`;
}
// For non-heading lines, return unchanged (preserving TimeIndex markers)
return line;
});
logger.debug(`Successfully converted ${convertedCount} TimeIndex markers in headings to Watch URLs`);
// Count remaining TimeIndex markers (should be preserved in transcript content)
const remainingContent = processedLines.join('\n');
const remainingTimeIndex = remainingContent.match(/\[TimeIndex:\d+\]/g);
const remainingCount = remainingTimeIndex ? remainingTimeIndex.length : 0;
logger.debug(`Preserved ${remainingCount} TimeIndex markers in non-heading content`);
return remainingContent;
}

139
src/utils/youtube-utils.ts Normal file
View file

@ -0,0 +1,139 @@
import { Notice } from 'obsidian';
export const YOUTUBE_URL_PLACEHOLDER = 'https://www.youtube.com/watch?v=...';
/**
* Helper method to validate YouTube URLs
* @param url URL to check
* @returns true if URL is a valid YouTube URL (video, playlist, or channel)
*/
export function isYoutubeUrl(url: string): boolean {
try {
// First check if it's a potentially valid YouTube domain
if (!url.match(/^(https?:\/\/)?(www\.|m\.)?(youtube\.com|youtu\.be)/i)) {
return false;
}
// Parse the URL to further validate structure
const urlObj = new URL(url);
// Check for video URLs
if (urlObj.hostname.includes('youtu.be') ||
(urlObj.hostname.includes('youtube.com') &&
(urlObj.pathname.includes('/watch') ||
urlObj.pathname.includes('/shorts') ||
urlObj.pathname.includes('/live') ||
urlObj.pathname.includes('/embed') ||
urlObj.pathname.includes('/v/')))) {
return true;
}
// Check for playlist URLs
if (urlObj.hostname.includes('youtube.com') &&
(urlObj.pathname.includes('/playlist') ||
(urlObj.pathname.includes('/watch') && urlObj.searchParams.has('list')))) {
return true;
}
// Check for channel URLs
if (urlObj.hostname.includes('youtube.com') &&
(urlObj.pathname.includes('/@') ||
urlObj.pathname.includes('/channel/') ||
urlObj.pathname.includes('/c/') ||
urlObj.pathname.includes('/user/'))) {
return true;
}
// If we got here, it's a YouTube domain but not a valid video, playlist, or channel URL
return false;
} catch {
// If URL parsing fails, it's not a valid URL
return false;
}
}
/**
* Helper method to check if a URL points to a YouTube channel or playlist
* @param url URL to check
* @returns true if URL is a YouTube channel or playlist URL
*/
export function isYoutubeChannelOrPlaylistUrl(url: string): boolean {
// Channel URL patterns: /@username, /channel/ID, /c/customname, /user/username
// Playlist URL patterns: /playlist?list=ID
// Parse the URL to get path and query parameters
let urlObj;
try {
urlObj = new URL(url);
} catch {
// Invalid URL
return false;
}
// Check if it's a channel URL
if (urlObj.pathname.includes('/@') ||
urlObj.pathname.includes('/channel/') ||
urlObj.pathname.includes('/c/') ||
urlObj.pathname.includes('/user/')) {
return true;
}
// Check if it's an explicit playlist URL
if (urlObj.pathname.includes('/playlist')) {
return true;
}
// Special case: if URL has both watch?v= and list=, it's a video being viewed from a list, not a playlist URL
if (urlObj.pathname.includes('/watch') && urlObj.searchParams.has('v')) {
// If it has a video ID, it's a single video (even if viewed from a list)
return false;
}
// Otherwise, check if list= is in the URL but not as part of a watch URL
return urlObj.searchParams.has('list');
}
/**
* Helper method to extract channel name from a YouTube URL
* @param url Channel URL
* @returns Extracted channel name or a fallback
*/
export function extractChannelName(url: string): string {
try {
const urlObj = new URL(url);
// Handle /@username format
if (urlObj.pathname.includes('/@')) {
return urlObj.pathname.split('/@')[1].split('/')[0];
}
// Handle /channel/ID format
if (urlObj.pathname.includes('/channel/')) {
return urlObj.pathname.split('/channel/')[1].split('/')[0];
}
// Handle /c/customname format
if (urlObj.pathname.includes('/c/')) {
return urlObj.pathname.split('/c/')[1].split('/')[0];
}
// Handle /user/username format
if (urlObj.pathname.includes('/user/')) {
return urlObj.pathname.split('/user/')[1].split('/')[0];
}
return 'YouTube-Channel'; // Fallback name
} catch (error) {
console.error('Error extracting channel name:', error);
return 'YouTube-Channel';
}
}
/**
* Shows a notice with additional debug information
* @param message Message to display
* @param timeout Display duration in milliseconds
*/
export function showNotice(message: string, timeout: number = 5000): void {
new Notice(message, timeout);
}

1609
src/youtube-transcript.ts Normal file

File diff suppressed because it is too large Load diff

1149
styles.css Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,39 @@
---
title: "<% tp.user.title %>"
video_url: "<% tp.user.watchUrl || tp.user.videoUrl %>"
video_id: "<% tp.user.videoId %>"
thumbnail_url: "<% tp.user.thumbnailUrl %>"
created: <% tp.date.now() %>
tubesage_version: <% tp.user.version %>
tags: <% tp.user.llmTags %>
transcript: |
<% tp.user.transcript %>
---
<%*
const watchUrl = tp.user.watchUrl || tp.user.videoUrl;
const thumbnailUrl = tp.user.thumbnailUrl || `https://img.youtube.com/vi/${tp.user.videoId}/hqdefault.jpg`;
const fullSummary = tp.user.summary || '';
const headingIndex = fullSummary.search(/^#{1,6}\s/m);
const summaryIntro = headingIndex === -1 ? fullSummary : fullSummary.slice(0, headingIndex);
const summaryBody = headingIndex === -1 ? '' : fullSummary.slice(headingIndex);
const summaryIntroOneLine = summaryIntro.replace(/\s*\n\s*/g, ' ').trim();
-%>
# <% tp.user.title %>
![[Literaturenotes.png|banner+small p+ct]]
> [!tip] Mobile Thumbnail (fallback)
> <%* if (thumbnailUrl && watchUrl) { %>[![YouTube Thumbnail|400x225](<% thumbnailUrl %>)](<% watchUrl %>)<%* } else { %>Thumbnail unavailable for this URL<%* } %>
> [!info] YouTube Video:
> <%* if (watchUrl) { %>![Url|400x200](<% watchUrl %>)<%* } else { %><% tp.user.originalVideoUrl || tp.user.videoUrl %><%* } %>
>[!summary]
>>[!danger] AI :luc_bot: Generated with <% tp.user.llmProvider %>/<% tp.user.llmModel %>
>> <% summaryIntroOneLine %>
<% summaryBody %>
>[!tip] Thoughts while consuming content - As Input to ideas

17
tsconfig.eslint.json Normal file
View file

@ -0,0 +1,17 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"allowJs": true
},
"include": [
"**/*.ts",
"**/*.js",
"**/*.mjs",
"src/types/**/*.d.ts",
"types/**/*.d.ts"
],
"exclude": [
"node_modules",
"main.js"
]
}

31
tsconfig.json Normal file
View file

@ -0,0 +1,31 @@
{
"compilerOptions": {
"baseUrl": ".",
"inlineSourceMap": true,
"inlineSources": true,
"module": "ESNext",
"target": "ES2020",
"allowJs": true,
"noImplicitAny": true,
"moduleResolution": "bundler",
"importHelpers": true,
"isolatedModules": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"strictNullChecks": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"types": [
"node"
],
"lib": [
"DOM",
"ES2020"
],
},
"include": [
"**/*.ts",
"src/types/**/*.d.ts",
"types/**/*.d.ts"
]
}

4
types/css.d.ts vendored Normal file
View file

@ -0,0 +1,4 @@
declare module '*.css' {
const content: string;
export default content;
}