mirror of
https://github.com/rmccorkl/TubeSage.git
synced 2026-07-22 06:45:31 +00:00
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:
commit
d551490a6d
50 changed files with 21969 additions and 0 deletions
23
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
23
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal 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
35
.github/workflows/release.yml
vendored
Normal 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
49
.gitignore
vendored
Normal 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
6
.npmrc
Normal 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
21
CONTRIBUTING.md
Normal 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 you’d 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
29
MIT-license-tubesage.md
Normal 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
248
README.md
Normal 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
1
check.sh
Normal file
|
|
@ -0,0 +1 @@
|
|||
404: Not Found
|
||||
1
docs/.nojekyll
Normal file
1
docs/.nojekyll
Normal file
|
|
@ -0,0 +1 @@
|
|||
# This file tells GitHub Pages to not process the site with Jekyll
|
||||
BIN
docs/TubeSage.mp4
Normal file
BIN
docs/TubeSage.mp4
Normal file
Binary file not shown.
299
docs/data-flow-diagram.md
Normal file
299
docs/data-flow-diagram.md
Normal 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.
|
||||
813
docs/deep-research-report.md
Normal file
813
docs/deep-research-report.md
Normal 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. fileciteturn0file0
|
||||
|
||||
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. citeturn1view2turn4view1turn7view0
|
||||
|
||||
Once you have `baseUrl`, fetch captions via YouTube’s 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[]`. citeturn3search3turn12search5turn4view0
|
||||
|
||||
This report provides (a) a concrete implementation plan and (b) a drop‑in patch for `src/youtube-transcript.ts` to make `fetchTranscript()` reliable again, with explicit handling for language selection, translation (`tlang`), format parameter pitfalls (replace, don’t append), rate‑limiting, fallbacks, user‑visible 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 **2026‑03‑05**. fileciteturn0file0
|
||||
|
||||
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. citeturn11search0turn11search8turn11search2
|
||||
|
||||
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 watch‑page parsing and timedtext downloads (and making internal endpoints opt‑in). fileciteturn0file0
|
||||
|
||||
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. citeturn2search2turn10view0
|
||||
|
||||
## Watch-page captions extraction design
|
||||
|
||||
**Core idea:** fetch the watch HTML, extract `ytInitialPlayerResponse`, read caption tracks, choose a track, then fetch captions from the track’s `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. citeturn1view2turn4view1turn7view0
|
||||
|
||||
Field names like `baseUrl`, `vssId`, `languageCode`, and optional `kind` are consistent with multiple open implementations and type definitions. citeturn4view0turn4view1turn0search22
|
||||
|
||||
**`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 per‑event timing`) are documented by real payload examples and consumer code. citeturn3search3turn12search5turn4view0
|
||||
|
||||
### Robust extraction from watch HTML
|
||||
|
||||
Many “quick scripts” do brittle string splits on `ytInitialPlayerResponse = ...` and terminate on `;var` or `;</script>`. citeturn4view1turn4view2turn7view0
|
||||
|
||||
For a plugin you ship, the robust pattern is:
|
||||
|
||||
* Locate the marker: `ytInitialPlayerResponse` (handle `var ytInitialPlayerResponse =` and `ytInitialPlayerResponse =` variants). citeturn7view0turn4view1
|
||||
* From the first `{` after the marker, extract a **balanced JSON object** using brace counting while respecting strings/escapes (so nested objects don’t break and `}` inside strings is ignored). This is an implementation technique (not YouTube‑specific), but it directly addresses the brittleness shown in split‑based examples. citeturn4view1turn7view0
|
||||
|
||||
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. fileciteturn0file0
|
||||
|
||||
### Language selection heuristics that work in practice
|
||||
|
||||
Empirically (and in multiple reference scripts/libraries):
|
||||
|
||||
* `kind === "asr"` indicates **auto‑generated** captions. citeturn1view1turn12search20turn4view1
|
||||
* `vssId` often encodes track type and language; examples show leading `.` for “regular” and `a.` for auto‑generated (`a.en`). citeturn4view0turn4view1
|
||||
* If the desired language isn’t 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). citeturn4view1turn9search1
|
||||
|
||||
A pragmatic heuristic for a note‑taking plugin:
|
||||
|
||||
1. Prefer **manual** captions in requested language (exact match on BCP‑47, 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`). citeturn4view1turn9search1
|
||||
3. Else prefer **auto** captions in requested language. citeturn1view1turn4view1
|
||||
4. Else pick the “best available” track (manual first, then auto) and optionally translate. citeturn4view1turn1view1
|
||||
|
||||
### Query parameters that matter
|
||||
|
||||
**`fmt`**: You should request `fmt=json3` if you want structured segment timing and easy parsing. citeturn3search3turn4view1turn4view0
|
||||
|
||||
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. citeturn2search1turn4view0turn1view1
|
||||
|
||||
**`tlang`**: Add `tlang=<lang>` only when you are intentionally requesting a translation. citeturn4view1turn9search1
|
||||
|
||||
**Alternative formats**: timedtext can return WebVTT (useful for compatibility) via `fmt=vtt`, and other formats exist in tooling outputs. citeturn0search1turn2search4turn12search14
|
||||
|
||||
### 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. citeturn1view2turn4view1turn7view0
|
||||
|
||||
If you encounter a track that provides a cipher string (rare for captions, common for streaming formats), then:
|
||||
* If the cipher contains an already‑deciphered `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. citeturn11search8turn2search10turn0search10
|
||||
|
||||
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`). citeturn2search2turn11search12turn10view0
|
||||
* Fail fast (no retries) on deterministic failures like `FAILED_PRECONDITION` from internal endpoints. citeturn11search8turn11search0turn0file0
|
||||
|
||||
## 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`. citeturn4view1turn7view0
|
||||
2. If the HTML indicates consent interstitial or missing player JSON, retry once with a `CONSENT=YES+1` cookie (best-effort). citeturn8search1
|
||||
3. `extractInitialPlayerResponse(html)` using brace-balanced extraction. (This replaces brittle split tricks.) citeturn7view0turn4view1
|
||||
4. Read `captionTracks` and select with `pickTrack(tracks, requestedLang)`. citeturn1view2turn4view1turn0search22
|
||||
5. Build a final caption URL:
|
||||
* replace existing `fmt` with `json3` (don’t append duplicates). citeturn2search1turn4view0
|
||||
* add `tlang` only if you decided to translate. citeturn4view1turn9search1
|
||||
6. `fetchCaptionTrack(url)` and parse `json3` events into segments. citeturn3search3turn12search5
|
||||
|
||||
**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 didn’t expose `captionTracks`. citeturn12search1
|
||||
2. `youtubei` fallback behind an explicit **experimental** toggle (default off), fail fast on `FAILED_PRECONDITION`, as your own notes recommend. fileciteturn0file0
|
||||
3. Third-party Supadata fallback when configured (unchanged conceptually; just make messaging accurate). fileciteturn0file0
|
||||
|
||||
**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). fileciteturn0file0
|
||||
|
||||
### 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`. fileciteturn0file0
|
||||
* 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 “drop‑in”: `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(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, "\"")
|
||||
.replace(/'/g, "'")
|
||||
.replace(/'/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`. citeturn7view0turn4view1turn1view2
|
||||
* Auto-generated tracks are identified via `kind: "asr"` and/or `vssId` conventions such as `a.en`. citeturn1view1turn4view0turn4view1
|
||||
* `fmt=json3` is used to get `wireMagic: "pb3"` + `events` and parse segments. citeturn3search3turn12search5turn4view0
|
||||
* Existing `fmt` is replaced rather than appended because duplicates can lead to XML output and parsing failures. citeturn2search1turn4view0
|
||||
* `type=list` timedtext fallback is a known endpoint used to enumerate caption tracks. citeturn12search1turn12search13
|
||||
|
||||
### 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`. citeturn1view2turn7view0turn4view1
|
||||
|
||||
**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"
|
||||
```
|
||||
JSON3’s `wireMagic: "pb3"` and `events` structure is documented by real examples and consumer code. citeturn3search3turn12search5turn4view0
|
||||
|
||||
**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. citeturn0search1turn12search14
|
||||
|
||||
**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. citeturn12search1
|
||||
|
||||
## 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. citeturn4view1turn7view0
|
||||
|
||||
**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. citeturn4view1turn4view0turn1view1
|
||||
|
||||
**URL rewriting**
|
||||
* Input URLs:
|
||||
* `...&fmt=srv3&...`
|
||||
* `...&fmt=srv3&fmt=vtt&...`
|
||||
* Assert: result contains exactly one `fmt=json3`. This guards the known duplicate‑`fmt` pitfall. citeturn2search1turn4view0
|
||||
|
||||
**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. citeturn3search3turn12search5
|
||||
|
||||
**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. citeturn12search1turn12search6turn12search20
|
||||
|
||||
### 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. citeturn2search2turn10view0
|
||||
|
||||
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). fileciteturn0file0
|
||||
* **Unplayable / restricted**: `playabilityStatus.status != "OK"` (e.g., login/age restricted) → explain that local extraction can’t proceed without authentication. fileciteturn0file0
|
||||
* **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. citeturn8search1
|
||||
* **429 throttling**: surface “rate limited” and advise the user to retry later; do not spin retries. Rate limiting is repeatedly observed in tooling. citeturn2search2turn10view0
|
||||
* **fmt duplication**: ensure replacing `fmt` is mandatory; otherwise you can get XML and break JSON parsing. citeturn2search1turn4view0
|
||||
* **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. citeturn9search1turn4view1
|
||||
|
||||
## Migration checklist and method comparison
|
||||
|
||||
### Migration checklist
|
||||
|
||||
1. Implement the watch‑page `ytInitialPlayerResponse → captionTracks[].baseUrl` path as the default “local directed” route. fileciteturn0file0turn1view2
|
||||
2. Replace any `youtubei`-derived caption retrieval as the default; keep it only as an explicit experimental toggle and fail fast on `FAILED_PRECONDITION`. fileciteturn0file0turn11search8
|
||||
3. Ensure `fmt` is replaced (not appended) when requesting `json3`. citeturn2search1turn4view0
|
||||
4. Add timedtext `type=list` fallback to handle cases where watch HTML does not expose caption tracks. citeturn12search1
|
||||
5. Fix user messaging to only list fallbacks that actually ran (your notes call out the current misleading Supadata claim). fileciteturn0file0
|
||||
6. Add unit tests for: player response extraction, URL rewriting, JSON3 parsing, and track selection. citeturn3search3turn4view1
|
||||
|
||||
### 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. citeturn11search0turn11search8turn1view2turn4view1turn12search1
|
||||
|
||||
| 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 citeturn8search1turn7view0 | Default “local directed” transcript acquisition citeturn1view2turn4view1 |
|
||||
| Timedtext list + download (`/api/timedtext?type=list` → fetch chosen track) | Medium–High (good fallback) | Medium (XML parsing + URL construction) | Usually none beyond UA; can still be blocked/rate-limited citeturn12search1turn2search2 | Fallback when watch HTML lacks captionTracks citeturn12search1 |
|
||||
| Private internal API (`youtubei/v1/player`, `youtubei/v1/get_transcript`) | Low (brittle) | High (client context/version/integrity) | Special headers; can fail with `FAILED_PRECONDITION` citeturn11search8turn0file0 | Experimental toggle only; diagnostics; last resort fileciteturn0file0 |
|
||||
| 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 fileciteturn0file0 |
|
||||
|
||||
### 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. citeturn9search1turn9search11turn9search2
|
||||
10
docs/index.html
Normal file
10
docs/index.html
Normal 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>
|
||||
616
docs/obsidian-plugin-guidelines.md
Normal file
616
docs/obsidian-plugin-guidelines.md
Normal 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
34
docs/video.html
Normal 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
266
docs/workflow-diagram.md
Normal 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.
|
||||
184
docs/youtube-transcript-extraction-findings.md
Normal file
184
docs/youtube-transcript-extraction-findings.md
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
# YouTube Transcript Extraction (TubeSage) — Findings & Next Steps
|
||||
|
||||
## TL;DR
|
||||
|
||||
TubeSage’s “local transcript” extraction relied on YouTube’s **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, what’s 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 Obsidian’s `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 isn’t an LLM/provider issue; transcript extraction is failing upstream.
|
||||
|
||||
`FAILED_PRECONDITION` from `youtubei` generally indicates YouTube is rejecting requests that don’t 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 wasn’t 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` aren’t 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` (don’t 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
54
esbuild.config.mjs
Normal 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();
|
||||
}
|
||||
81
eslint-rules/prefer-active-doc-fixed.mjs
Normal file
81
eslint-rules/prefer-active-doc-fixed.mjs
Normal 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
71
eslint.config.mjs
Normal 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
6542
main.ts
Normal file
File diff suppressed because one or more lines are too long
10
manifest.json
Normal file
10
manifest.json
Normal 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
6507
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
57
package.json
Normal file
57
package.json
Normal 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
141
src/llm/gemini-client.ts
Normal 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
314
src/llm/langchain-client.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
93
src/llm/langchain-fetcher.ts
Normal file
93
src/llm/langchain-fetcher.ts
Normal 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
45
src/llm/llm-factory.ts
Normal 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
205
src/llm/ollama-client.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
230
src/llm/transcript-summarizer.ts
Normal file
230
src/llm/transcript-summarizer.ts
Normal 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
23
src/types/electron-remote.d.ts
vendored
Normal 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
33
src/types/hh-mm-ss.d.ts
vendored
Normal 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
56
src/types/langchain.d.ts
vendored
Normal 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
22
src/types/youtube-transcript-api.d.ts
vendored
Normal 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
149
src/utils/error-utils.ts
Normal 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
273
src/utils/fetch-shim.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
48
src/utils/filename-sanitizer.ts
Normal file
48
src/utils/filename-sanitizer.ts
Normal 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
255
src/utils/form-utils.ts
Normal 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
271
src/utils/logger.ts
Normal 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');
|
||||
240
src/utils/model-limits-registry.ts
Normal file
240
src/utils/model-limits-registry.ts
Normal 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
110
src/utils/path-utils.ts
Normal 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
157
src/utils/prompt-utils.ts
Normal 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
|
||||
}
|
||||
359
src/utils/timestamp-utils.ts
Normal file
359
src/utils/timestamp-utils.ts
Normal 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
139
src/utils/youtube-utils.ts
Normal 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
1609
src/youtube-transcript.ts
Normal file
File diff suppressed because it is too large
Load diff
1149
styles.css
Normal file
1149
styles.css
Normal file
File diff suppressed because it is too large
Load diff
39
templates/YouTubeTranscript.md
Normal file
39
templates/YouTubeTranscript.md
Normal 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) { %>[](<% watchUrl %>)<%* } else { %>Thumbnail unavailable for this URL<%* } %>
|
||||
|
||||
|
||||
> [!info] YouTube Video:
|
||||
> <%* if (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
17
tsconfig.eslint.json
Normal 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
31
tsconfig.json
Normal 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
4
types/css.d.ts
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
declare module '*.css' {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
Loading…
Reference in a new issue