ALI7ADEL commited on
Commit
ed147e2
·
verified ·
1 Parent(s): 631deff

Upload 51 files

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. Dockerfile +18 -0
  2. requirements.txt +24 -0
  3. run.py +137 -0
  4. src/__init__.py +0 -0
  5. src/__pycache__/__init__.cpython-312.pyc +0 -0
  6. src/api/__init__.py +0 -0
  7. src/api/__pycache__/__init__.cpython-312.pyc +0 -0
  8. src/api/__pycache__/analytics_routes.cpython-312.pyc +0 -0
  9. src/api/__pycache__/auth_routes.cpython-312.pyc +0 -0
  10. src/api/__pycache__/main.cpython-312.pyc +0 -0
  11. src/api/__pycache__/notes_routes.cpython-312.pyc +0 -0
  12. src/api/analytics_routes.py +139 -0
  13. src/api/auth_routes.py +162 -0
  14. src/api/main.py +365 -0
  15. src/api/notes_routes.py +155 -0
  16. src/audio/__init__.py +0 -0
  17. src/audio/__pycache__/__init__.cpython-312.pyc +0 -0
  18. src/audio/__pycache__/downloader.cpython-312.pyc +0 -0
  19. src/audio/__pycache__/processor.cpython-312.pyc +0 -0
  20. src/audio/downloader.py +171 -0
  21. src/audio/processor.py +102 -0
  22. src/auth/__init__.py +24 -0
  23. src/auth/__pycache__/__init__.cpython-312.pyc +0 -0
  24. src/auth/__pycache__/dependencies.cpython-312.pyc +0 -0
  25. src/auth/__pycache__/security.cpython-312.pyc +0 -0
  26. src/auth/dependencies.py +96 -0
  27. src/auth/security.py +113 -0
  28. src/db/__init__.py +14 -0
  29. src/db/__pycache__/__init__.cpython-312.pyc +0 -0
  30. src/db/__pycache__/database.cpython-312.pyc +0 -0
  31. src/db/__pycache__/models.cpython-312.pyc +0 -0
  32. src/db/database.py +37 -0
  33. src/db/models.py +81 -0
  34. src/summarization/__init__.py +0 -0
  35. src/summarization/__pycache__/__init__.cpython-312.pyc +0 -0
  36. src/summarization/__pycache__/note_generator.cpython-312.pyc +0 -0
  37. src/summarization/__pycache__/segmenter.cpython-312.pyc +0 -0
  38. src/summarization/note_generator.py +239 -0
  39. src/summarization/segmenter.py +173 -0
  40. src/transcription/__init__.py +0 -0
  41. src/transcription/__pycache__/__init__.cpython-312.pyc +0 -0
  42. src/transcription/__pycache__/whisper_transcriber.cpython-312.pyc +0 -0
  43. src/transcription/whisper_transcriber.py +186 -0
  44. src/ui/__init__.py +0 -0
  45. src/ui/app.html +461 -0
  46. src/utils/__init__.py +0 -0
  47. src/utils/__pycache__/__init__.cpython-312.pyc +0 -0
  48. src/utils/__pycache__/config.cpython-312.pyc +0 -0
  49. src/utils/__pycache__/logger.cpython-312.pyc +0 -0
  50. src/utils/config.py +107 -0
Dockerfile ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.9-slim
2
+
3
+ RUN apt-get update && apt-get install -y \
4
+ ffmpeg \
5
+ git \
6
+ && rm -rf /var/lib/apt/lists/*
7
+
8
+ WORKDIR /app
9
+
10
+ COPY requirements.txt .
11
+ RUN pip install --no-cache-dir -r requirements.txt
12
+
13
+ COPY . .
14
+
15
+ RUN chmod -R 777 /app
16
+
17
+ ENV PORT=7860
18
+ CMD ["python", "run.py", "server"]
requirements.txt ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ yt-dlp==2024.12.23
2
+ pydub==0.25.1
3
+ openai-whisper==20231117
4
+ torch
5
+ torchaudio
6
+ google-generativeai==0.3.2
7
+ langchain==0.1.0
8
+ langchain-google-genai==0.0.5
9
+ fastapi==0.109.0
10
+ uvicorn[standard]==0.27.0
11
+ pydantic==2.5.3
12
+ pydantic-settings==2.1.0
13
+ python-multipart==0.0.6
14
+ python-dotenv==1.0.0
15
+ httpx==0.26.0
16
+ aiofiles==23.2.1
17
+ sqlmodel==0.0.14
18
+ asyncpg==0.29.0
19
+ greenlet==3.0.3
20
+ passlib[bcrypt]==1.7.4
21
+ python-jose[cryptography]==3.3.0
22
+ bcrypt==4.1.2
23
+ pytest==8.0.0
24
+ pytest-asyncio==0.23.3
run.py ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Main entry point for YouTube Study Notes AI.
3
+ Provides CLI interface and server startup.
4
+ """
5
+
6
+ import sys
7
+ import argparse
8
+ from pathlib import Path
9
+
10
+ # Import necessary modules for server and middleware
11
+ from src.utils.logger import setup_logger
12
+ from src.utils.config import settings
13
+
14
+ logger = setup_logger(__name__)
15
+
16
+
17
+ def run_server():
18
+ """Start the FastAPI server with CORS enabled for Flutter Web."""
19
+ import uvicorn
20
+ from fastapi.middleware.cors import CORSMiddleware
21
+ from src.api.main import app # Import the app instance directly
22
+
23
+ logger.info("Configuring CORS for Flutter Web...")
24
+
25
+ # Add CORS Middleware to allow requests from Chrome/Flutter
26
+ app.add_middleware(
27
+ CORSMiddleware,
28
+ allow_origins=["*"], # Allows all origins
29
+ allow_credentials=True,
30
+ allow_methods=["*"], # Allows all methods
31
+ allow_headers=["*"], # Allows all headers
32
+ )
33
+
34
+ logger.info("Starting YouTube Study Notes AI server...")
35
+ logger.info(
36
+ f"Server will be available at http://{settings.api_host}:{settings.api_port}"
37
+ )
38
+ logger.info(
39
+ f"API Documentation: http://{settings.api_host}:{settings.api_port}/docs"
40
+ )
41
+
42
+ # Run the server using the app object directly
43
+ # Note: reload is disabled here to ensure CORS settings are applied correctly from this script
44
+ uvicorn.run(app, host=settings.api_host, port=settings.api_port, log_level="info")
45
+
46
+
47
+ def run_cli(youtube_url: str, output_file: str = None):
48
+ """
49
+ Run note generation from command line.
50
+
51
+ Args:
52
+ youtube_url: YouTube video URL
53
+ output_file: Optional output file path
54
+ """
55
+ from src.audio.downloader import YouTubeDownloader
56
+ from src.transcription.whisper_transcriber import WhisperTranscriber
57
+ from src.summarization.note_generator import NoteGenerator
58
+
59
+ logger.info("Starting CLI mode")
60
+ logger.info(f"Processing URL: {youtube_url}")
61
+
62
+ try:
63
+ # Step 1: Download audio
64
+ logger.info("Step 1/3: Downloading audio...")
65
+ downloader = YouTubeDownloader()
66
+ video_info = downloader.get_video_info(youtube_url)
67
+ audio_file = downloader.download_audio(youtube_url)
68
+
69
+ # Step 2: Transcribe
70
+ logger.info("Step 2/3: Transcribing audio...")
71
+ transcriber = WhisperTranscriber()
72
+ transcript_data = transcriber.transcribe(audio_file)
73
+
74
+ # Step 3: Generate notes
75
+ logger.info("Step 3/3: Generating notes...")
76
+ note_gen = NoteGenerator()
77
+ notes = note_gen.generate_notes_from_full_transcript(
78
+ transcript_data["text"], video_info["title"]
79
+ )
80
+
81
+ # Format and save
82
+ final_notes = note_gen.format_final_notes(
83
+ notes, video_info["title"], youtube_url, video_info["duration"]
84
+ )
85
+
86
+ if output_file:
87
+ output_path = Path(output_file)
88
+ else:
89
+ output_path = settings.output_dir / f"{video_info['title'][:50]}_notes.md"
90
+
91
+ output_path.write_text(final_notes, encoding="utf-8")
92
+
93
+ logger.info(f"✅ Notes saved to: {output_path}")
94
+ print(f"\n✅ Success! Notes saved to: {output_path}")
95
+
96
+ # Cleanup
97
+ downloader.cleanup(audio_file)
98
+
99
+ except Exception as e:
100
+ logger.error(f"Failed: {e}")
101
+ print(f"\n❌ Error: {e}")
102
+ sys.exit(1)
103
+
104
+
105
+ def main():
106
+ """Main entry point with argument parsing."""
107
+ parser = argparse.ArgumentParser(
108
+ description="YouTube Study Notes AI - Generate structured notes from educational videos"
109
+ )
110
+
111
+ parser.add_argument(
112
+ "mode",
113
+ choices=["server", "cli"],
114
+ help="Run mode: server (API + web UI) or cli (direct processing)",
115
+ )
116
+
117
+ parser.add_argument(
118
+ "--url", type=str, help="YouTube video URL (required for cli mode)"
119
+ )
120
+
121
+ parser.add_argument(
122
+ "--output", type=str, help="Output file path (optional for cli mode)"
123
+ )
124
+
125
+ args = parser.parse_args()
126
+
127
+ if args.mode == "server":
128
+ run_server()
129
+ elif args.mode == "cli":
130
+ if not args.url:
131
+ print("Error: --url is required for cli mode")
132
+ sys.exit(1)
133
+ run_cli(args.url, args.output)
134
+
135
+
136
+ if __name__ == "__main__":
137
+ main()
src/__init__.py ADDED
File without changes
src/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (140 Bytes). View file
 
src/api/__init__.py ADDED
File without changes
src/api/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (144 Bytes). View file
 
src/api/__pycache__/analytics_routes.cpython-312.pyc ADDED
Binary file (5.72 kB). View file
 
src/api/__pycache__/auth_routes.cpython-312.pyc ADDED
Binary file (7 kB). View file
 
src/api/__pycache__/main.cpython-312.pyc ADDED
Binary file (13.8 kB). View file
 
src/api/__pycache__/notes_routes.cpython-312.pyc ADDED
Binary file (6.15 kB). View file
 
src/api/analytics_routes.py ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Analytics API endpoints for user statistics.
3
+ """
4
+
5
+ from typing import Dict, List
6
+ from fastapi import APIRouter, Depends
7
+ from pydantic import BaseModel, Field
8
+ from sqlmodel import Session, select, func
9
+ from datetime import datetime
10
+
11
+ from src.db.database import get_session
12
+ from src.db.models import User, Note
13
+ from src.auth.dependencies import get_current_user
14
+ from src.utils.logger import setup_logger
15
+
16
+ logger = setup_logger(__name__)
17
+
18
+ router = APIRouter(prefix="/analytics", tags=["Analytics"])
19
+
20
+
21
+ # Response Models
22
+ class AnalyticsResponse(BaseModel):
23
+ """Response model for user analytics."""
24
+ total_videos_processed: int = Field(..., description="Total number of videos processed")
25
+ total_study_time_seconds: int = Field(..., description="Total study time in seconds")
26
+ total_study_time_formatted: str = Field(..., description="Total study time formatted (HH:MM:SS)")
27
+ total_notes: int = Field(..., description="Total number of notes generated")
28
+ average_video_duration: float = Field(..., description="Average video duration in seconds")
29
+ languages_used: List[str] = Field(..., description="List of languages used")
30
+ recent_activity: List[Dict] = Field(..., description="Recent notes (last 5)")
31
+
32
+ class Config:
33
+ json_schema_extra = {
34
+ "example": {
35
+ "total_videos_processed": 15,
36
+ "total_study_time_seconds": 18000,
37
+ "total_study_time_formatted": "5:00:00",
38
+ "total_notes": 15,
39
+ "average_video_duration": 1200.0,
40
+ "languages_used": ["en", "es"],
41
+ "recent_activity": [
42
+ {
43
+ "video_title": "Python Basics",
44
+ "created_at": "2024-01-27T05:00:00",
45
+ "duration": 1800
46
+ }
47
+ ]
48
+ }
49
+ }
50
+
51
+
52
+ def format_duration(seconds: int) -> str:
53
+ """
54
+ Format duration in seconds to HH:MM:SS format.
55
+
56
+ Args:
57
+ seconds: Duration in seconds
58
+
59
+ Returns:
60
+ Formatted duration string
61
+ """
62
+ if seconds is None or seconds == 0:
63
+ return "0:00:00"
64
+
65
+ hours = seconds // 3600
66
+ minutes = (seconds % 3600) // 60
67
+ secs = seconds % 60
68
+
69
+ return f"{hours}:{minutes:02d}:{secs:02d}"
70
+
71
+
72
+ @router.get("", response_model=AnalyticsResponse)
73
+ async def get_analytics(
74
+ current_user: User = Depends(get_current_user),
75
+ session: Session = Depends(get_session)
76
+ ):
77
+ """
78
+ Get user statistics and analytics.
79
+
80
+ **Protected Route**: Requires authentication
81
+
82
+ Returns comprehensive statistics including:
83
+ - Total videos processed
84
+ - Total study time (sum of video durations)
85
+ - Average video duration
86
+ - Languages used
87
+ - Recent activity
88
+ """
89
+ # Get all notes for the user
90
+ statement = select(Note).where(Note.user_id == current_user.id)
91
+ notes = session.exec(statement).all()
92
+
93
+ total_notes = len(notes)
94
+
95
+ # Calculate total study time (sum of video durations)
96
+ total_study_time = 0
97
+ durations = []
98
+ for note in notes:
99
+ if note.video_duration:
100
+ total_study_time += note.video_duration
101
+ durations.append(note.video_duration)
102
+
103
+ # Calculate average duration
104
+ average_duration = sum(durations) / len(durations) if durations else 0
105
+
106
+ # Get unique languages
107
+ languages = list(set(note.language for note in notes if note.language))
108
+
109
+ # Get recent activity (last 5 notes)
110
+ recent_statement = (
111
+ select(Note)
112
+ .where(Note.user_id == current_user.id)
113
+ .order_by(Note.created_at.desc())
114
+ .limit(5)
115
+ )
116
+ recent_notes = session.exec(recent_statement).all()
117
+
118
+ recent_activity = [
119
+ {
120
+ "video_title": note.video_title,
121
+ "video_url": note.video_url,
122
+ "created_at": str(note.created_at),
123
+ "duration": note.video_duration,
124
+ "duration_formatted": format_duration(note.video_duration) if note.video_duration else "N/A"
125
+ }
126
+ for note in recent_notes
127
+ ]
128
+
129
+ logger.info(f"Analytics retrieved for user {current_user.email}")
130
+
131
+ return AnalyticsResponse(
132
+ total_videos_processed=total_notes,
133
+ total_study_time_seconds=total_study_time,
134
+ total_study_time_formatted=format_duration(total_study_time),
135
+ total_notes=total_notes,
136
+ average_video_duration=round(average_duration, 2),
137
+ languages_used=languages,
138
+ recent_activity=recent_activity
139
+ )
src/api/auth_routes.py ADDED
@@ -0,0 +1,162 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Authentication API endpoints for user signup and login.
3
+ """
4
+
5
+ from datetime import timedelta
6
+ from fastapi import APIRouter, Depends, HTTPException, status
7
+ from fastapi.security import OAuth2PasswordRequestForm
8
+ from pydantic import BaseModel, EmailStr, Field
9
+ from sqlmodel import select
10
+ from sqlmodel.ext.asyncio.session import AsyncSession
11
+
12
+ from src.db.database import get_session
13
+ from src.db.models import User
14
+ from src.auth.security import hash_password, verify_password, create_access_token
15
+ from src.utils.logger import setup_logger
16
+ from src.utils.config import settings
17
+
18
+ logger = setup_logger(__name__)
19
+
20
+ router = APIRouter(prefix="/auth", tags=["Authentication"])
21
+
22
+
23
+ # Request/Response Models
24
+ class SignupRequest(BaseModel):
25
+ """Request model for user signup."""
26
+ email: EmailStr = Field(..., description="User email address")
27
+ username: str = Field(..., min_length=3, max_length=50, description="Username")
28
+ password: str = Field(..., min_length=6, description="Password (min 6 characters)")
29
+
30
+ class Config:
31
+ json_schema_extra = {
32
+ "example": {
33
+ "email": "student@example.com",
34
+ "username": "Student123",
35
+ "password": "secure_password"
36
+ }
37
+ }
38
+
39
+
40
+ class UserResponse(BaseModel):
41
+ """Response model for user data (without password)."""
42
+ id: int
43
+ email: str
44
+ username: str
45
+ role: str
46
+ created_at: str
47
+
48
+ class Config:
49
+ json_schema_extra = {
50
+ "example": {
51
+ "id": 1,
52
+ "email": "student@example.com",
53
+ "username": "Student123",
54
+ "role": "user",
55
+ "created_at": "2024-01-27T05:00:00"
56
+ }
57
+ }
58
+
59
+
60
+ class TokenResponse(BaseModel):
61
+ """Response model for login token."""
62
+ access_token: str = Field(..., description="JWT access token")
63
+ token_type: str = Field(default="bearer", description="Token type")
64
+ expires_in: int = Field(..., description="Token expiration time in minutes")
65
+
66
+ class Config:
67
+ json_schema_extra = {
68
+ "example": {
69
+ "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
70
+ "token_type": "bearer",
71
+ "expires_in": 60
72
+ }
73
+ }
74
+
75
+
76
+ @router.post("/signup", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
77
+ async def signup(
78
+ signup_data: SignupRequest,
79
+ session: AsyncSession = Depends(get_session)
80
+ ):
81
+ """
82
+ Register a new user.
83
+ """
84
+ # Check if email or username already exists
85
+ statement = select(User).where(
86
+ (User.email == signup_data.email) | (User.username == signup_data.username)
87
+ )
88
+ result = await session.exec(statement)
89
+ existing_user = result.first()
90
+
91
+ if existing_user:
92
+ raise HTTPException(
93
+ status_code=status.HTTP_409_CONFLICT,
94
+ detail="Email or Username already registered"
95
+ )
96
+
97
+ # Create new user with hashed password
98
+ hashed_password_value = hash_password(signup_data.password)
99
+
100
+ new_user = User(
101
+ email=signup_data.email,
102
+ username=signup_data.username,
103
+ password_hash=hashed_password_value,
104
+ role="user"
105
+ )
106
+
107
+ session.add(new_user)
108
+ await session.commit()
109
+ await session.refresh(new_user)
110
+
111
+ logger.info(f"New user registered: {new_user.email}")
112
+
113
+ return UserResponse(
114
+ id=new_user.id,
115
+ email=new_user.email,
116
+ username=new_user.username,
117
+ role=new_user.role,
118
+ created_at=str(new_user.created_at)
119
+ )
120
+
121
+
122
+ @router.post("/login", response_model=TokenResponse)
123
+ async def login(
124
+ form_data: OAuth2PasswordRequestForm = Depends(),
125
+ session: AsyncSession = Depends(get_session)
126
+ ):
127
+ """
128
+ Authenticate user and return JWT access token.
129
+ """
130
+ # Find user by username
131
+ statement = select(User).where(User.username == form_data.username)
132
+ result = await session.exec(statement)
133
+ user = result.first()
134
+
135
+ # If not found by username, try finding by email
136
+ if not user:
137
+ statement = select(User).where(User.email == form_data.username)
138
+ result = await session.exec(statement)
139
+ user = result.first()
140
+
141
+ # Verify user exists and password is correct
142
+ if not user or not verify_password(form_data.password, user.password_hash):
143
+ raise HTTPException(
144
+ status_code=status.HTTP_401_UNAUTHORIZED,
145
+ detail="Incorrect username or password",
146
+ headers={"WWW-Authenticate": "Bearer"},
147
+ )
148
+
149
+ # Create access token
150
+ access_token_expires = timedelta(minutes=settings.access_token_expire_minutes)
151
+ access_token = create_access_token(
152
+ data={"sub": user.username},
153
+ expires_delta=access_token_expires
154
+ )
155
+
156
+ logger.info(f"User logged in: {user.username}")
157
+
158
+ return TokenResponse(
159
+ access_token=access_token,
160
+ token_type="bearer",
161
+ expires_in=settings.access_token_expire_minutes
162
+ )
src/api/main.py ADDED
@@ -0,0 +1,365 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FastAPI application for YouTube study notes generation.
3
+ Provides REST API endpoints for note generation and status tracking.
4
+ """
5
+
6
+ import asyncio
7
+ import uuid
8
+ from datetime import datetime
9
+ from pathlib import Path
10
+ from typing import Dict, Optional
11
+ from enum import Enum
12
+ from contextlib import asynccontextmanager
13
+
14
+ from fastapi import FastAPI, HTTPException, BackgroundTasks
15
+ from fastapi.middleware.cors import CORSMiddleware
16
+ from fastapi.responses import FileResponse
17
+ from pydantic import BaseModel, HttpUrl, Field
18
+
19
+ from src.audio.downloader import YouTubeDownloader
20
+ from src.audio.processor import AudioProcessor
21
+ from src.transcription.whisper_transcriber import WhisperTranscriber
22
+ from src.summarization.segmenter import TranscriptSegmenter
23
+ from src.summarization.note_generator import NoteGenerator
24
+ from src.utils.logger import setup_logger
25
+ from src.utils.config import settings
26
+ from src.db.database import create_db_and_tables
27
+
28
+ logger = setup_logger(__name__)
29
+
30
+
31
+ # Pydantic Models
32
+ class TaskStatus(str, Enum):
33
+ """Task processing status."""
34
+
35
+ PENDING = "pending"
36
+ DOWNLOADING = "downloading"
37
+ TRANSCRIBING = "transcribing"
38
+ GENERATING_NOTES = "generating_notes"
39
+ COMPLETED = "completed"
40
+ FAILED = "failed"
41
+
42
+
43
+ class GenerateNotesRequest(BaseModel):
44
+ """Request model for note generation."""
45
+
46
+ youtube_url: HttpUrl = Field(..., description="YouTube video URL")
47
+ language: str = Field(default="en", description="Video language code")
48
+
49
+ class Config:
50
+ json_schema_extra = {
51
+ "example": {
52
+ "youtube_url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
53
+ "language": "en",
54
+ }
55
+ }
56
+
57
+
58
+ class TaskResponse(BaseModel):
59
+ """Response model for task creation."""
60
+
61
+ task_id: str = Field(..., description="Unique task identifier")
62
+ status: TaskStatus = Field(..., description="Current task status")
63
+ message: str = Field(..., description="Status message")
64
+
65
+
66
+ class TaskStatusResponse(BaseModel):
67
+ """Response model for task status queries."""
68
+
69
+ task_id: str
70
+ status: TaskStatus
71
+ message: str
72
+ video_title: Optional[str] = None
73
+ progress: Optional[int] = Field(None, description="Progress percentage (0-100)")
74
+ notes_file: Optional[str] = None
75
+ created_at: datetime
76
+ updated_at: datetime
77
+
78
+
79
+ # Global task storage (in production, use a database)
80
+ tasks: Dict[str, Dict] = {}
81
+
82
+
83
+ # --- Lifespan Event Handler (Fixes Windows Event Loop Issue) ---
84
+ @asynccontextmanager
85
+ async def lifespan(app: FastAPI):
86
+ """
87
+ Handle startup and shutdown events.
88
+ Initializes the database tables when the server starts.
89
+ """
90
+ logger.info("Lifespan: Initializing database tables...")
91
+ await create_db_and_tables()
92
+ logger.info("Lifespan: Database tables initialized successfully")
93
+ yield
94
+ logger.info("Lifespan: Server shutting down...")
95
+
96
+
97
+ # FastAPI app
98
+ app = FastAPI(
99
+ title="YouTube Study Notes AI",
100
+ description="Generate structured study notes from YouTube educational videos",
101
+ version="1.0.0",
102
+ lifespan=lifespan,
103
+ )
104
+
105
+ # CORS middleware
106
+ app.add_middleware(
107
+ CORSMiddleware,
108
+ allow_origins=["*"], # In production, specify allowed origins
109
+ allow_credentials=True,
110
+ allow_methods=["*"],
111
+ allow_headers=["*"],
112
+ )
113
+
114
+ # Include routers
115
+ from src.api.auth_routes import router as auth_router
116
+ from src.api.notes_routes import router as notes_router
117
+ from src.api.analytics_routes import router as analytics_router
118
+
119
+ app.include_router(auth_router)
120
+ app.include_router(notes_router)
121
+ app.include_router(analytics_router)
122
+
123
+
124
+ @app.get("/")
125
+ async def root():
126
+ """Root endpoint with API information."""
127
+ return {
128
+ "name": "YouTube Study Notes AI",
129
+ "version": "1.0.0",
130
+ "description": "Generate structured study notes from YouTube videos with user management",
131
+ "endpoints": {
132
+ "authentication": {
133
+ "signup": "POST /auth/signup",
134
+ "login": "POST /auth/login",
135
+ },
136
+ "notes": {
137
+ "create": "POST /notes",
138
+ "list": "GET /notes",
139
+ "get": "GET /notes/{note_id}",
140
+ "delete": "DELETE /notes/{note_id}",
141
+ },
142
+ "analytics": {"user_stats": "GET /analytics"},
143
+ "generation": {
144
+ "generate_notes": "POST /generate-notes",
145
+ "check_status": "GET /status/{task_id}",
146
+ "download_notes": "GET /download/{task_id}",
147
+ },
148
+ },
149
+ "documentation": {"swagger_ui": "/docs", "redoc": "/redoc"},
150
+ }
151
+
152
+
153
+ @app.post("/generate-notes", response_model=TaskResponse)
154
+ async def generate_notes(
155
+ request: GenerateNotesRequest, background_tasks: BackgroundTasks
156
+ ):
157
+ """
158
+ Generate study notes from a YouTube video.
159
+
160
+ This endpoint starts an async task to process the video.
161
+ Use the returned task_id to check status and download results.
162
+ """
163
+ try:
164
+ # Generate unique task ID
165
+ task_id = str(uuid.uuid4())
166
+
167
+ # Initialize task
168
+ tasks[task_id] = {
169
+ "status": TaskStatus.PENDING,
170
+ "message": "Task created, starting processing...",
171
+ "youtube_url": str(request.youtube_url),
172
+ "language": request.language,
173
+ "video_title": None,
174
+ "progress": 0,
175
+ "notes_file": None,
176
+ "created_at": datetime.now(),
177
+ "updated_at": datetime.now(),
178
+ }
179
+
180
+ # Start background processing
181
+ background_tasks.add_task(
182
+ process_video, task_id, str(request.youtube_url), request.language
183
+ )
184
+
185
+ logger.info(f"Created task {task_id} for URL: {request.youtube_url}")
186
+
187
+ return TaskResponse(
188
+ task_id=task_id,
189
+ status=TaskStatus.PENDING,
190
+ message="Processing started. Use task_id to check status.",
191
+ )
192
+
193
+ except Exception as e:
194
+ logger.error(f"Failed to create task: {e}")
195
+ raise HTTPException(status_code=500, detail=str(e))
196
+
197
+
198
+ @app.get("/status/{task_id}", response_model=TaskStatusResponse)
199
+ async def get_status(task_id: str):
200
+ """Get the current status of a processing task."""
201
+ if task_id not in tasks:
202
+ raise HTTPException(status_code=404, detail="Task not found")
203
+
204
+ task = tasks[task_id]
205
+
206
+ return TaskStatusResponse(
207
+ task_id=task_id,
208
+ status=task["status"],
209
+ message=task["message"],
210
+ video_title=task.get("video_title"),
211
+ progress=task.get("progress"),
212
+ notes_file=task.get("notes_file"),
213
+ created_at=task["created_at"],
214
+ updated_at=task["updated_at"],
215
+ )
216
+
217
+
218
+ @app.get("/download/{task_id}")
219
+ async def download_notes(task_id: str):
220
+ """Download the generated notes file."""
221
+ if task_id not in tasks:
222
+ raise HTTPException(status_code=404, detail="Task not found")
223
+
224
+ task = tasks[task_id]
225
+
226
+ if task["status"] != TaskStatus.COMPLETED:
227
+ raise HTTPException(
228
+ status_code=400, detail=f"Notes not ready. Current status: {task['status']}"
229
+ )
230
+
231
+ notes_file = task.get("notes_file")
232
+ if not notes_file or not Path(notes_file).exists():
233
+ raise HTTPException(status_code=404, detail="Notes file not found")
234
+
235
+ return FileResponse(
236
+ notes_file, media_type="text/markdown", filename=Path(notes_file).name
237
+ )
238
+
239
+
240
+ async def process_video(task_id: str, youtube_url: str, language: str):
241
+ """
242
+ Background task to process video and generate notes.
243
+
244
+ Args:
245
+ task_id: Unique task identifier
246
+ youtube_url: YouTube video URL
247
+ language: Video language code
248
+ """
249
+ audio_file = None
250
+
251
+ try:
252
+ # Update status: Downloading
253
+ update_task(task_id, TaskStatus.DOWNLOADING, "Downloading video...", 10)
254
+
255
+ # Download video and extract audio
256
+ downloader = YouTubeDownloader()
257
+
258
+ # Get video info
259
+ video_info = downloader.get_video_info(youtube_url)
260
+ video_title = video_info["title"]
261
+ video_duration = video_info["duration"]
262
+
263
+ update_task(
264
+ task_id,
265
+ TaskStatus.DOWNLOADING,
266
+ f"Downloading: {video_title}",
267
+ 20,
268
+ video_title=video_title,
269
+ )
270
+
271
+ audio_file = downloader.download_audio(youtube_url, task_id)
272
+
273
+ # Validate audio
274
+ processor = AudioProcessor()
275
+ if not processor.validate_audio_file(audio_file):
276
+ raise ValueError("Invalid audio file")
277
+
278
+ # Update status: Transcribing
279
+ update_task(task_id, TaskStatus.TRANSCRIBING, "Transcribing audio...", 40)
280
+
281
+ # Transcribe audio
282
+ transcriber = WhisperTranscriber()
283
+ transcript_data = transcriber.transcribe(audio_file, language=language)
284
+
285
+ update_task(task_id, TaskStatus.TRANSCRIBING, "Transcription complete", 60)
286
+
287
+ # Update status: Generating notes
288
+ update_task(
289
+ task_id, TaskStatus.GENERATING_NOTES, "Generating structured notes...", 70
290
+ )
291
+
292
+ # Segment transcript
293
+ segmenter = TranscriptSegmenter()
294
+
295
+ # For shorter transcripts, process as a whole
296
+ # For longer ones, segment first
297
+ word_count = len(transcript_data["text"].split())
298
+
299
+ if word_count < 2000:
300
+ # Short video: process full transcript
301
+ logger.info("Processing short video (full transcript)")
302
+ note_gen = NoteGenerator()
303
+ notes = note_gen.generate_notes_from_full_transcript(
304
+ transcript_data["text"], video_title
305
+ )
306
+ else:
307
+ # Long video: segment and process
308
+ logger.info("Processing long video (segmented)")
309
+ segments = segmenter.segment_transcript(transcript_data, method="time")
310
+
311
+ note_gen = NoteGenerator()
312
+ notes = note_gen.generate_notes_from_segments(segments)
313
+
314
+ # Add title
315
+ notes = f"# {video_title}\n\n{notes}"
316
+
317
+ update_task(task_id, TaskStatus.GENERATING_NOTES, "Formatting notes...", 90)
318
+
319
+ # Format final notes with metadata
320
+ final_notes = note_gen.format_final_notes(
321
+ notes, video_title, youtube_url, video_duration
322
+ )
323
+
324
+ # Save notes to file
325
+ notes_file = settings.output_dir / f"{task_id}_notes.md"
326
+ notes_file.write_text(final_notes, encoding="utf-8")
327
+
328
+ # Update status: Completed
329
+ update_task(
330
+ task_id,
331
+ TaskStatus.COMPLETED,
332
+ "Notes generated successfully!",
333
+ 100,
334
+ notes_file=str(notes_file),
335
+ )
336
+
337
+ logger.info(f"Task {task_id} completed successfully")
338
+
339
+ except Exception as e:
340
+ logger.error(f"Task {task_id} failed: {e}")
341
+ update_task(task_id, TaskStatus.FAILED, f"Processing failed: {str(e)}", 0)
342
+
343
+ finally:
344
+ # Cleanup audio file
345
+ if audio_file and audio_file.exists():
346
+ try:
347
+ downloader.cleanup(audio_file)
348
+ except Exception as e:
349
+ logger.warning(f"Cleanup failed: {e}")
350
+
351
+
352
+ def update_task(
353
+ task_id: str, status: TaskStatus, message: str, progress: int, **kwargs
354
+ ):
355
+ """Update task status and metadata."""
356
+ if task_id in tasks:
357
+ tasks[task_id].update(
358
+ {
359
+ "status": status,
360
+ "message": message,
361
+ "progress": progress,
362
+ "updated_at": datetime.now(),
363
+ **kwargs,
364
+ }
365
+ )
src/api/notes_routes.py ADDED
@@ -0,0 +1,155 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Notes management API endpoints.
3
+ """
4
+
5
+ from typing import List, Optional
6
+ from pathlib import Path
7
+ import os
8
+
9
+ from fastapi import APIRouter, Depends, HTTPException, status, Query
10
+ from fastapi.responses import FileResponse, JSONResponse
11
+ from pydantic import BaseModel, HttpUrl, Field
12
+ from sqlmodel import Session, select
13
+
14
+ from src.db.database import get_session
15
+ from src.db.models import User, Note
16
+ from src.auth.dependencies import get_current_user
17
+ from src.utils.logger import setup_logger
18
+ from src.utils.config import settings
19
+
20
+ logger = setup_logger(__name__)
21
+
22
+ router = APIRouter(prefix="/notes", tags=["Notes"])
23
+
24
+
25
+ # --- New Models for File-based Notes ---
26
+ class GeneratedNoteFile(BaseModel):
27
+ filename: str
28
+ title: str
29
+ created_at: float
30
+ size: int
31
+
32
+
33
+ # --- Existing Models ---
34
+ class CreateNoteRequest(BaseModel):
35
+ video_url: HttpUrl = Field(..., description="YouTube video URL")
36
+ video_title: str = Field(..., max_length=500, description="Video title")
37
+ summary_text: str = Field(..., description="Generated study notes in markdown")
38
+ video_duration: Optional[int] = Field(None, description="Video duration in seconds")
39
+ language: str = Field(
40
+ default="en", max_length=10, description="Video language code"
41
+ )
42
+
43
+
44
+ class NoteResponse(BaseModel):
45
+ id: int
46
+ video_url: str
47
+ video_title: str
48
+ summary_text: str
49
+ video_duration: Optional[int]
50
+ language: str
51
+ user_id: int
52
+ created_at: str
53
+
54
+
55
+ # ==========================================
56
+ # ✅ NEW ENDPOINTS: Read from 'outputs' folder
57
+ # ==========================================
58
+
59
+
60
+ @router.get("/generated", response_model=List[GeneratedNoteFile])
61
+ async def list_generated_notes():
62
+ """
63
+ List all markdown files found in the 'outputs' directory.
64
+ This bypasses the database to show files directly.
65
+ """
66
+ notes = []
67
+ output_dir = settings.output_dir
68
+
69
+ # Create directory if it doesn't exist
70
+ if not output_dir.exists():
71
+ return []
72
+
73
+ # Scan for .md files
74
+ # We look for files ending with _notes.md
75
+ for file_path in output_dir.glob("*_notes.md"):
76
+ try:
77
+ # Try to read the first line to get a clean title
78
+ content = file_path.read_text(encoding="utf-8")
79
+ lines = content.split("\n")
80
+ # Usually the first line is "# Title"
81
+ title = lines[0].replace("#", "").strip() if lines else file_path.name
82
+
83
+ stats = file_path.stat()
84
+
85
+ notes.append(
86
+ GeneratedNoteFile(
87
+ filename=file_path.name,
88
+ title=title if title else file_path.name,
89
+ created_at=stats.st_mtime,
90
+ size=stats.st_size,
91
+ )
92
+ )
93
+ except Exception as e:
94
+ logger.error(f"Error reading file {file_path}: {e}")
95
+ continue
96
+
97
+ # Sort by newest first
98
+ notes.sort(key=lambda x: x.created_at, reverse=True)
99
+ return notes
100
+
101
+
102
+ @router.get("/generated/{filename}")
103
+ async def get_generated_note_content(filename: str):
104
+ """
105
+ Get the full content of a specific markdown file.
106
+ """
107
+ # Security check: prevent directory traversal
108
+ if ".." in filename or "/" in filename:
109
+ raise HTTPException(status_code=400, detail="Invalid filename")
110
+
111
+ file_path = settings.output_dir / filename
112
+
113
+ if not file_path.exists():
114
+ raise HTTPException(status_code=404, detail="Note file not found")
115
+
116
+ content = file_path.read_text(encoding="utf-8")
117
+ return {"content": content, "filename": filename}
118
+
119
+
120
+ # ==========================================
121
+ # End of New Endpoints
122
+ # ==========================================
123
+
124
+ # ... (Database endpoints kept for compatibility if needed later) ...
125
+ # You can leave the rest of the file as is, or I can include it below just in case.
126
+ # For brevity, I'll include the standard DB create/get just to not break anything.
127
+
128
+
129
+ @router.post("", response_model=NoteResponse, status_code=status.HTTP_201_CREATED)
130
+ async def create_note(
131
+ note_data: CreateNoteRequest,
132
+ current_user: User = Depends(get_current_user),
133
+ session: Session = Depends(get_session),
134
+ ):
135
+ new_note = Note(
136
+ video_url=str(note_data.video_url),
137
+ video_title=note_data.video_title,
138
+ summary_text=note_data.summary_text,
139
+ video_duration=note_data.video_duration,
140
+ language=note_data.language,
141
+ user_id=current_user.id,
142
+ )
143
+ session.add(new_note)
144
+ session.commit()
145
+ session.refresh(new_note)
146
+ return NoteResponse(
147
+ id=new_note.id,
148
+ video_url=new_note.video_url,
149
+ video_title=new_note.video_title,
150
+ summary_text=new_note.summary_text,
151
+ video_duration=new_note.video_duration,
152
+ language=new_note.language,
153
+ user_id=new_note.user_id,
154
+ created_at=str(new_note.created_at),
155
+ )
src/audio/__init__.py ADDED
File without changes
src/audio/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (146 Bytes). View file
 
src/audio/__pycache__/downloader.cpython-312.pyc ADDED
Binary file (7.16 kB). View file
 
src/audio/__pycache__/processor.cpython-312.pyc ADDED
Binary file (4.5 kB). View file
 
src/audio/downloader.py ADDED
@@ -0,0 +1,171 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ YouTube video downloader and audio extraction module.
3
+ Uses yt-dlp for robust YouTube video handling.
4
+ """
5
+
6
+ import re
7
+ from pathlib import Path
8
+ from typing import Dict, Optional
9
+ import yt_dlp
10
+
11
+ from src.utils.logger import setup_logger
12
+ from src.utils.config import settings
13
+
14
+ logger = setup_logger(__name__)
15
+
16
+
17
+ class YouTubeDownloader:
18
+ """Handles YouTube video downloading and audio extraction."""
19
+
20
+ def __init__(self, output_dir: Optional[Path] = None):
21
+ """
22
+ Initialize the YouTube downloader.
23
+
24
+ Args:
25
+ output_dir: Directory to save downloaded audio files
26
+ """
27
+ self.output_dir = output_dir or settings.temp_dir
28
+ self.output_dir.mkdir(parents=True, exist_ok=True)
29
+
30
+ @staticmethod
31
+ def is_valid_youtube_url(url: str) -> bool:
32
+ """
33
+ Validate if the URL is a valid YouTube link.
34
+
35
+ Args:
36
+ url: YouTube URL to validate
37
+
38
+ Returns:
39
+ True if valid YouTube URL, False otherwise
40
+ """
41
+ youtube_regex = (
42
+ r'(https?://)?(www\.)?'
43
+ r'(youtube|youtu|youtube-nocookie)\.(com|be)/'
44
+ r'(watch\?v=|embed/|v/|.+\?v=)?([^&=%\?]{11})'
45
+ )
46
+
47
+ match = re.match(youtube_regex, url)
48
+ return bool(match)
49
+
50
+ def get_video_info(self, url: str) -> Dict[str, any]:
51
+ """
52
+ Get video information without downloading.
53
+
54
+ Args:
55
+ url: YouTube video URL
56
+
57
+ Returns:
58
+ Dictionary containing video metadata
59
+
60
+ Raises:
61
+ ValueError: If URL is invalid or video is unavailable
62
+ """
63
+ if not self.is_valid_youtube_url(url):
64
+ raise ValueError(f"Invalid YouTube URL: {url}")
65
+
66
+ ydl_opts = {
67
+ 'quiet': True,
68
+ 'no_warnings': True,
69
+ }
70
+
71
+ try:
72
+ with yt_dlp.YoutubeDL(ydl_opts) as ydl:
73
+ info = ydl.extract_info(url, download=False)
74
+
75
+ return {
76
+ 'title': info.get('title', 'Unknown'),
77
+ 'duration': info.get('duration', 0),
78
+ 'uploader': info.get('uploader', 'Unknown'),
79
+ 'description': info.get('description', ''),
80
+ 'thumbnail': info.get('thumbnail', ''),
81
+ 'upload_date': info.get('upload_date', ''),
82
+ }
83
+ except Exception as e:
84
+ logger.error(f"Failed to get video info: {e}")
85
+ raise ValueError(f"Could not access video: {str(e)}")
86
+
87
+ def download_audio(self, url: str, video_id: Optional[str] = None) -> Path:
88
+ """
89
+ Download YouTube video and extract audio.
90
+
91
+ Args:
92
+ url: YouTube video URL
93
+ video_id: Optional custom identifier for the output file
94
+
95
+ Returns:
96
+ Path to the downloaded audio file
97
+
98
+ Raises:
99
+ ValueError: If URL is invalid or download fails
100
+ RuntimeError: If video exceeds maximum duration
101
+ """
102
+ if not self.is_valid_youtube_url(url):
103
+ raise ValueError(f"Invalid YouTube URL: {url}")
104
+
105
+ # Get video info to check duration
106
+ info = self.get_video_info(url)
107
+ duration = info['duration']
108
+
109
+ if duration > settings.max_video_duration:
110
+ raise RuntimeError(
111
+ f"Video duration ({duration}s) exceeds maximum allowed "
112
+ f"({settings.max_video_duration}s)"
113
+ )
114
+
115
+ # Generate output filename
116
+ if video_id:
117
+ output_template = str(self.output_dir / f"{video_id}.%(ext)s")
118
+ else:
119
+ output_template = str(self.output_dir / "%(id)s.%(ext)s")
120
+
121
+ # yt-dlp options for audio extraction
122
+ ydl_opts = {
123
+ 'format': 'bestaudio/best',
124
+ 'postprocessors': [{
125
+ 'key': 'FFmpegExtractAudio',
126
+ 'preferredcodec': 'wav',
127
+ 'preferredquality': '192',
128
+ }],
129
+ 'outtmpl': output_template,
130
+ 'quiet': False,
131
+ 'no_warnings': False,
132
+ 'extract_flat': False,
133
+ }
134
+
135
+ try:
136
+ logger.info(f"Downloading audio from: {url}")
137
+ logger.info(f"Video title: {info['title']}")
138
+ logger.info(f"Duration: {duration}s ({duration/60:.1f} minutes)")
139
+
140
+ with yt_dlp.YoutubeDL(ydl_opts) as ydl:
141
+ result = ydl.extract_info(url, download=True)
142
+
143
+ # Get the output filename
144
+ if video_id:
145
+ audio_file = self.output_dir / f"{video_id}.wav"
146
+ else:
147
+ audio_file = self.output_dir / f"{result['id']}.wav"
148
+
149
+ if not audio_file.exists():
150
+ raise RuntimeError("Audio file was not created")
151
+
152
+ logger.info(f"Audio downloaded successfully: {audio_file}")
153
+ return audio_file
154
+
155
+ except Exception as e:
156
+ logger.error(f"Failed to download audio: {e}")
157
+ raise ValueError(f"Download failed: {str(e)}")
158
+
159
+ def cleanup(self, file_path: Path) -> None:
160
+ """
161
+ Remove downloaded audio file.
162
+
163
+ Args:
164
+ file_path: Path to the file to remove
165
+ """
166
+ try:
167
+ if file_path.exists():
168
+ file_path.unlink()
169
+ logger.info(f"Cleaned up file: {file_path}")
170
+ except Exception as e:
171
+ logger.warning(f"Failed to cleanup file {file_path}: {e}")
src/audio/processor.py ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Audio preprocessing utilities.
3
+ Handles noise reduction, normalization, and format validation.
4
+ """
5
+
6
+ from pathlib import Path
7
+ from typing import Optional
8
+ import wave
9
+
10
+ from src.utils.logger import setup_logger
11
+
12
+ logger = setup_logger(__name__)
13
+
14
+
15
+ class AudioProcessor:
16
+ """Handles audio preprocessing and validation."""
17
+
18
+ @staticmethod
19
+ def get_audio_duration(audio_path: Path) -> float:
20
+ """
21
+ Get the duration of an audio file in seconds.
22
+
23
+ Args:
24
+ audio_path: Path to the audio file
25
+
26
+ Returns:
27
+ Duration in seconds
28
+ """
29
+ try:
30
+ with wave.open(str(audio_path), 'r') as audio_file:
31
+ frames = audio_file.getnframes()
32
+ rate = audio_file.getframerate()
33
+ duration = frames / float(rate)
34
+ return duration
35
+ except Exception as e:
36
+ logger.warning(f"Could not get audio duration: {e}")
37
+ return 0.0
38
+
39
+ @staticmethod
40
+ def validate_audio_file(audio_path: Path) -> bool:
41
+ """
42
+ Validate that the audio file is readable and properly formatted.
43
+
44
+ Args:
45
+ audio_path: Path to the audio file
46
+
47
+ Returns:
48
+ True if valid, False otherwise
49
+ """
50
+ if not audio_path.exists():
51
+ logger.error(f"Audio file does not exist: {audio_path}")
52
+ return False
53
+
54
+ if audio_path.stat().st_size == 0:
55
+ logger.error(f"Audio file is empty: {audio_path}")
56
+ return False
57
+
58
+ try:
59
+ with wave.open(str(audio_path), 'r') as audio_file:
60
+ # Check basic properties
61
+ channels = audio_file.getnchannels()
62
+ sample_width = audio_file.getsampwidth()
63
+ framerate = audio_file.getframerate()
64
+
65
+ logger.info(
66
+ f"Audio validation: {channels} channels, "
67
+ f"{sample_width} bytes/sample, {framerate} Hz"
68
+ )
69
+
70
+ return True
71
+ except Exception as e:
72
+ logger.error(f"Audio file validation failed: {e}")
73
+ return False
74
+
75
+ @staticmethod
76
+ def get_audio_info(audio_path: Path) -> dict:
77
+ """
78
+ Get detailed information about an audio file.
79
+
80
+ Args:
81
+ audio_path: Path to the audio file
82
+
83
+ Returns:
84
+ Dictionary with audio properties
85
+ """
86
+ try:
87
+ with wave.open(str(audio_path), 'r') as audio_file:
88
+ frames = audio_file.getnframes()
89
+ rate = audio_file.getframerate()
90
+ duration = frames / float(rate)
91
+
92
+ return {
93
+ 'channels': audio_file.getnchannels(),
94
+ 'sample_width': audio_file.getsampwidth(),
95
+ 'framerate': rate,
96
+ 'frames': frames,
97
+ 'duration': duration,
98
+ 'file_size': audio_path.stat().st_size,
99
+ }
100
+ except Exception as e:
101
+ logger.error(f"Failed to get audio info: {e}")
102
+ return {}
src/auth/__init__.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Authentication module for YouTube Study Notes AI.
3
+ Provides secure password hashing and JWT token management.
4
+ """
5
+
6
+ from .security import (
7
+ hash_password,
8
+ verify_password,
9
+ create_access_token,
10
+ decode_access_token,
11
+ )
12
+ from .dependencies import (
13
+ get_current_user,
14
+ get_current_active_user,
15
+ )
16
+
17
+ __all__ = [
18
+ "hash_password",
19
+ "verify_password",
20
+ "create_access_token",
21
+ "decode_access_token",
22
+ "get_current_user",
23
+ "get_current_active_user",
24
+ ]
src/auth/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (563 Bytes). View file
 
src/auth/__pycache__/dependencies.cpython-312.pyc ADDED
Binary file (3.17 kB). View file
 
src/auth/__pycache__/security.cpython-312.pyc ADDED
Binary file (3.87 kB). View file
 
src/auth/dependencies.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FastAPI dependencies for authentication and authorization.
3
+ """
4
+
5
+ from typing import Optional
6
+ from fastapi import Depends, HTTPException, status
7
+ from fastapi.security import OAuth2PasswordBearer
8
+ from sqlmodel import Session, select
9
+
10
+ from src.db.database import get_session
11
+ from src.db.models import User
12
+ from src.auth.security import decode_access_token
13
+
14
+ # OAuth2 scheme for extracting bearer tokens from Authorization header
15
+ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
16
+
17
+
18
+ async def get_current_user(
19
+ token: str = Depends(oauth2_scheme),
20
+ session: Session = Depends(get_session)
21
+ ) -> User:
22
+ """
23
+ Get the currently authenticated user from JWT token.
24
+
25
+ This dependency extracts the JWT token from the Authorization header,
26
+ validates it, and retrieves the corresponding user from the database.
27
+
28
+ Args:
29
+ token: JWT token from Authorization header
30
+ session: Database session
31
+
32
+ Returns:
33
+ User object if authentication is successful
34
+
35
+ Raises:
36
+ HTTPException: 401 Unauthorized if token is invalid or user not found
37
+
38
+ Usage:
39
+ @app.get("/protected")
40
+ async def protected_route(current_user: User = Depends(get_current_user)):
41
+ return {"message": f"Hello {current_user.username}"}
42
+ """
43
+ credentials_exception = HTTPException(
44
+ status_code=status.HTTP_401_UNAUTHORIZED,
45
+ detail="Could not validate credentials",
46
+ headers={"WWW-Authenticate": "Bearer"},
47
+ )
48
+
49
+ # Decode the token
50
+ payload = decode_access_token(token)
51
+ if payload is None:
52
+ raise credentials_exception
53
+
54
+ # Extract user email from token
55
+ email: Optional[str] = payload.get("sub")
56
+ if email is None:
57
+ raise credentials_exception
58
+
59
+ # Retrieve user from database
60
+ statement = select(User).where(User.email == email)
61
+ user = session.exec(statement).first()
62
+
63
+ if user is None:
64
+ raise credentials_exception
65
+
66
+ return user
67
+
68
+
69
+ async def get_current_active_user(
70
+ current_user: User = Depends(get_current_user)
71
+ ) -> User:
72
+ """
73
+ Get the current active user (for future soft-delete support).
74
+
75
+ Currently returns the user as-is, but can be extended to check
76
+ for account status, email verification, banned users, etc.
77
+
78
+ Args:
79
+ current_user: User from get_current_user dependency
80
+
81
+ Returns:
82
+ User object if user is active
83
+
84
+ Raises:
85
+ HTTPException: 400 Bad Request if user is inactive
86
+
87
+ Usage:
88
+ @app.get("/protected")
89
+ async def protected_route(user: User = Depends(get_current_active_user)):
90
+ return {"message": f"Hello active user {user.username}"}
91
+ """
92
+ # Future: Check if user.is_active, user.is_verified, etc.
93
+ # if not current_user.is_active:
94
+ # raise HTTPException(status_code=400, detail="Inactive user")
95
+
96
+ return current_user
src/auth/security.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Security utilities for password hashing and JWT token management.
3
+ """
4
+
5
+ from datetime import datetime, timedelta
6
+ from typing import Optional
7
+ from jose import JWTError, jwt
8
+ from passlib.context import CryptContext
9
+
10
+ from src.utils.config import settings
11
+
12
+ # Password hashing context using bcrypt
13
+ pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
14
+
15
+
16
+ def hash_password(password: str) -> str:
17
+ """
18
+ Hash a plain-text password using bcrypt.
19
+
20
+ Args:
21
+ password: Plain-text password to hash
22
+
23
+ Returns:
24
+ Hashed password string
25
+
26
+ Example:
27
+ >>> hashed = hash_password("my_secret_password")
28
+ >>> print(hashed)
29
+ $2b$12$...
30
+ """
31
+ return pwd_context.hash(password)
32
+
33
+
34
+ def verify_password(plain_password: str, hashed_password: str) -> bool:
35
+ """
36
+ Verify a plain-text password against a hashed password.
37
+
38
+ Args:
39
+ plain_password: Plain-text password to verify
40
+ hashed_password: Hashed password to compare against
41
+
42
+ Returns:
43
+ True if password matches, False otherwise
44
+
45
+ Example:
46
+ >>> hashed = hash_password("my_password")
47
+ >>> verify_password("my_password", hashed)
48
+ True
49
+ >>> verify_password("wrong_password", hashed)
50
+ False
51
+ """
52
+ return pwd_context.verify(plain_password, hashed_password)
53
+
54
+
55
+ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
56
+ """
57
+ Create a JWT access token.
58
+
59
+ Args:
60
+ data: Dictionary of claims to encode in the token (e.g., {"sub": "user@example.com"})
61
+ expires_delta: Optional custom expiration time
62
+
63
+ Returns:
64
+ Encoded JWT token string
65
+
66
+ Example:
67
+ >>> token = create_access_token({"sub": "user@example.com"})
68
+ >>> print(token)
69
+ eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
70
+ """
71
+ to_encode = data.copy()
72
+
73
+ if expires_delta:
74
+ expire = datetime.utcnow() + expires_delta
75
+ else:
76
+ expire = datetime.utcnow() + timedelta(minutes=settings.access_token_expire_minutes)
77
+
78
+ to_encode.update({"exp": expire})
79
+
80
+ encoded_jwt = jwt.encode(
81
+ to_encode,
82
+ settings.secret_key,
83
+ algorithm=settings.algorithm
84
+ )
85
+
86
+ return encoded_jwt
87
+
88
+
89
+ def decode_access_token(token: str) -> Optional[dict]:
90
+ """
91
+ Decode and verify a JWT access token.
92
+
93
+ Args:
94
+ token: JWT token string to decode
95
+
96
+ Returns:
97
+ Dictionary of claims if token is valid, None otherwise
98
+
99
+ Example:
100
+ >>> token = create_access_token({"sub": "user@example.com"})
101
+ >>> payload = decode_access_token(token)
102
+ >>> print(payload["sub"])
103
+ user@example.com
104
+ """
105
+ try:
106
+ payload = jwt.decode(
107
+ token,
108
+ settings.secret_key,
109
+ algorithms=[settings.algorithm]
110
+ )
111
+ return payload
112
+ except JWTError:
113
+ return None
src/db/__init__.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Database module for YouTube Study Notes AI.
3
+ Provides ORM models and database connection management.
4
+ """
5
+
6
+ from .models import User, Note
7
+ from .database import create_db_and_tables, get_session
8
+
9
+ __all__ = [
10
+ "User",
11
+ "Note",
12
+ "create_db_and_tables",
13
+ "get_session",
14
+ ]
src/db/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (441 Bytes). View file
 
src/db/__pycache__/database.cpython-312.pyc ADDED
Binary file (2.02 kB). View file
 
src/db/__pycache__/models.cpython-312.pyc ADDED
Binary file (3.7 kB). View file
 
src/db/database.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Database connection and session management.
3
+ """
4
+
5
+ from typing import AsyncGenerator
6
+ from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
7
+ from sqlalchemy.orm import sessionmaker
8
+ from sqlmodel import SQLModel
9
+ from sqlmodel.ext.asyncio.session import AsyncSession
10
+
11
+ from src.utils.config import settings
12
+
13
+ async_engine: AsyncEngine = create_async_engine(
14
+ settings.database_url,
15
+ echo=False,
16
+ future=True,
17
+ connect_args={"statement_cache_size": 0}
18
+ )
19
+
20
+ async def create_db_and_tables():
21
+ """Create database tables defined in SQLModel models."""
22
+ async with async_engine.begin() as conn:
23
+ await conn.run_sync(SQLModel.metadata.create_all)
24
+
25
+ async def get_session() -> AsyncGenerator[AsyncSession, None]:
26
+ """
27
+ Dependency to provide async database session.
28
+ Yields:
29
+ AsyncSession: Database session
30
+ """
31
+ async_session = sessionmaker(
32
+ bind=async_engine,
33
+ class_=AsyncSession,
34
+ expire_on_commit=False
35
+ )
36
+ async with async_session() as session:
37
+ yield session
src/db/models.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ SQLModel database models for PostgreSQL (Supabase).
3
+ Optimized for cloud deployment and mobile app integration.
4
+ """
5
+
6
+ from datetime import datetime
7
+ from typing import Optional, List
8
+ from sqlmodel import SQLModel, Field, Relationship
9
+
10
+
11
+ class User(SQLModel, table=True):
12
+ """
13
+ User model for authentication and note ownership.
14
+
15
+ Attributes:
16
+ id: Primary key, auto-incremented
17
+ email: Unique email address for login
18
+ username: Display name for the user
19
+ password_hash: Bcrypt hashed password
20
+ role: User role (default: "user")
21
+ created_at: Account creation timestamp
22
+ notes: Relationship to user's notes
23
+ """
24
+ __tablename__ = "users"
25
+
26
+ id: Optional[int] = Field(default=None, primary_key=True)
27
+ email: str = Field(unique=True, index=True, max_length=255, nullable=False)
28
+ username: str = Field(max_length=100, nullable=False)
29
+ password_hash: str = Field(max_length=255, nullable=False)
30
+ role: str = Field(default="user", max_length=50, nullable=False)
31
+ created_at: datetime = Field(default_factory=datetime.utcnow, nullable=False)
32
+
33
+ # Relationship to notes
34
+ notes: List["Note"] = Relationship(back_populates="owner")
35
+
36
+ class Config:
37
+ """Pydantic configuration."""
38
+ json_schema_extra = {
39
+ "example": {
40
+ "email": "student@example.com",
41
+ "username": "Student123",
42
+ "role": "user"
43
+ }
44
+ }
45
+
46
+
47
+ class Note(SQLModel, table=True):
48
+ """
49
+ Note model for storing generated study notes.
50
+
51
+ Attributes:
52
+ id: Primary key, auto-incremented
53
+ user_id: Foreign key to the user who generated this note
54
+ video_url: YouTube video URL
55
+ video_title: Title of the processed video
56
+ summary_content: Generated study notes content (markdown)
57
+ created_at: Note generation timestamp
58
+ owner: Relationship to the user who owns this note
59
+ """
60
+ __tablename__ = "notes"
61
+
62
+ id: Optional[int] = Field(default=None, primary_key=True)
63
+ user_id: int = Field(foreign_key="users.id", index=True, nullable=False)
64
+ video_url: str = Field(index=True, max_length=500, nullable=False)
65
+ video_title: str = Field(max_length=500, nullable=False)
66
+ summary_content: str = Field(nullable=False) # Full markdown content
67
+ created_at: datetime = Field(default_factory=datetime.utcnow, nullable=False)
68
+
69
+ # Relationship to user
70
+ owner: Optional[User] = Relationship(back_populates="notes")
71
+
72
+ class Config:
73
+ """Pydantic configuration."""
74
+ json_schema_extra = {
75
+ "example": {
76
+ "user_id": 1,
77
+ "video_url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
78
+ "video_title": "Introduction to Python Programming",
79
+ "summary_content": "# Study Notes\\n\\n## Key Concepts..."
80
+ }
81
+ }
src/summarization/__init__.py ADDED
File without changes
src/summarization/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (154 Bytes). View file
 
src/summarization/__pycache__/note_generator.cpython-312.pyc ADDED
Binary file (9.07 kB). View file
 
src/summarization/__pycache__/segmenter.cpython-312.pyc ADDED
Binary file (6.07 kB). View file
 
src/summarization/note_generator.py ADDED
@@ -0,0 +1,239 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ LLM-based note generation module.
3
+ Uses Google Gemini to generate structured study notes from transcripts.
4
+ """
5
+
6
+ from typing import Dict, List, Optional
7
+ import google.generativeai as genai
8
+
9
+ from src.utils.logger import setup_logger
10
+ from src.utils.config import settings
11
+
12
+ logger = setup_logger(__name__)
13
+
14
+
15
+ class NoteGenerator:
16
+ """Generates structured study notes using LLM."""
17
+
18
+ # System prompt for note generation
19
+ SYSTEM_PROMPT = """You are an expert educational note-taker. Your task is to convert video transcripts into clear, structured study notes.
20
+
21
+ Follow these guidelines:
22
+ 1. Create a clear hierarchical structure with section titles
23
+ 2. Use bullet points for key information
24
+ 3. Highlight important concepts and definitions
25
+ 4. Extract key terms and explain them
26
+ 5. Be concise but comprehensive
27
+ 6. Focus on educational content, skip irrelevant parts
28
+ 7. Use proper Markdown formatting
29
+
30
+ Format the output as follows:
31
+ # [Main Topic/Title]
32
+
33
+ ## [Section 1 Title]
34
+ - Key point 1
35
+ - Key point 2
36
+ - Sub-point if needed
37
+ - **Important term**: Definition or explanation
38
+
39
+ ## [Section 2 Title]
40
+ ...
41
+
42
+ ## Key Concepts
43
+ - **Concept 1**: Explanation
44
+ - **Concept 2**: Explanation
45
+ """
46
+
47
+ def __init__(self, api_key: Optional[str] = None, model_name: str = "gemini-2.5-flash"):
48
+ """
49
+ Initialize the note generator.
50
+
51
+ Args:
52
+ api_key: Google Gemini API key (defaults to config)
53
+ model_name: Gemini model to use
54
+ """
55
+ self.api_key = api_key or settings.google_api_key
56
+ self.model_name = model_name
57
+
58
+ # Configure Gemini
59
+ genai.configure(api_key=self.api_key)
60
+ self.model = genai.GenerativeModel(model_name)
61
+
62
+ logger.info(f"Initialized NoteGenerator with model: {model_name}")
63
+
64
+ def generate_notes_from_segment(self, segment_text: str) -> str:
65
+ """
66
+ Generate notes from a single transcript segment.
67
+
68
+ Args:
69
+ segment_text: Text segment to process
70
+
71
+ Returns:
72
+ Generated notes in Markdown format
73
+ """
74
+ try:
75
+ prompt = f"{self.SYSTEM_PROMPT}\n\nTranscript:\n{segment_text}\n\nGenerate structured study notes:"
76
+
77
+ logger.debug(f"Generating notes for segment ({len(segment_text)} chars)")
78
+
79
+ response = self.model.generate_content(prompt)
80
+ notes = response.text
81
+
82
+ logger.debug(f"Generated {len(notes)} characters of notes")
83
+
84
+ return notes.strip()
85
+
86
+ except Exception as e:
87
+ logger.error(f"Failed to generate notes: {e}")
88
+ return f"## Error\nFailed to generate notes for this segment: {str(e)}"
89
+
90
+ def generate_notes_from_segments(self, segments: List[Dict]) -> str:
91
+ """
92
+ Generate notes from multiple transcript segments.
93
+
94
+ Args:
95
+ segments: List of transcript segments
96
+
97
+ Returns:
98
+ Combined notes in Markdown format
99
+ """
100
+ all_notes = []
101
+
102
+ logger.info(f"Generating notes from {len(segments)} segments")
103
+
104
+ for i, segment in enumerate(segments, 1):
105
+ logger.info(f"Processing segment {i}/{len(segments)}")
106
+
107
+ segment_text = segment.get('text', '')
108
+ if not segment_text:
109
+ continue
110
+
111
+ # Add timestamp if available
112
+ if 'start' in segment:
113
+ timestamp = self._format_timestamp(segment['start'])
114
+ all_notes.append(f"\n---\n**Timestamp: {timestamp}**\n")
115
+
116
+ # Generate notes for this segment
117
+ notes = self.generate_notes_from_segment(segment_text)
118
+ all_notes.append(notes)
119
+
120
+ # Combine all notes
121
+ combined_notes = "\n\n".join(all_notes)
122
+
123
+ logger.info(f"Generated total of {len(combined_notes)} characters")
124
+
125
+ return combined_notes
126
+
127
+ def generate_notes_from_full_transcript(
128
+ self,
129
+ transcript_text: str,
130
+ video_title: str = "Educational Video"
131
+ ) -> str:
132
+ """
133
+ Generate notes from full transcript (for shorter videos).
134
+
135
+ Args:
136
+ transcript_text: Full transcript text
137
+ video_title: Title of the video
138
+
139
+ Returns:
140
+ Generated notes in Markdown format
141
+ """
142
+ try:
143
+ prompt = f"""{self.SYSTEM_PROMPT}
144
+
145
+ Video Title: {video_title}
146
+
147
+ Transcript:
148
+ {transcript_text}
149
+
150
+ Generate comprehensive structured study notes:"""
151
+
152
+ logger.info(f"Generating notes from full transcript ({len(transcript_text)} chars)")
153
+
154
+ response = self.model.generate_content(prompt)
155
+ notes = response.text
156
+
157
+ # Add header with video title
158
+ final_notes = f"# {video_title}\n\n{notes.strip()}"
159
+
160
+ logger.info(f"Generated {len(final_notes)} characters of notes")
161
+
162
+ return final_notes
163
+
164
+ except Exception as e:
165
+ logger.error(f"Failed to generate notes from full transcript: {e}")
166
+ raise RuntimeError(f"Note generation failed: {str(e)}")
167
+
168
+ def generate_summary(self, notes: str) -> str:
169
+ """
170
+ Generate a brief summary of the notes.
171
+
172
+ Args:
173
+ notes: Generated study notes
174
+
175
+ Returns:
176
+ Brief summary
177
+ """
178
+ try:
179
+ prompt = f"""Provide a brief 2-3 sentence summary of these study notes:
180
+
181
+ {notes}
182
+
183
+ Summary:"""
184
+
185
+ response = self.model.generate_content(prompt)
186
+ summary = response.text.strip()
187
+
188
+ return summary
189
+
190
+ except Exception as e:
191
+ logger.error(f"Failed to generate summary: {e}")
192
+ return "Summary generation failed."
193
+
194
+ @staticmethod
195
+ def _format_timestamp(seconds: float) -> str:
196
+ """Format seconds into MM:SS or HH:MM:SS."""
197
+ hours = int(seconds // 3600)
198
+ minutes = int((seconds % 3600) // 60)
199
+ secs = int(seconds % 60)
200
+
201
+ if hours > 0:
202
+ return f"{hours:02d}:{minutes:02d}:{secs:02d}"
203
+ else:
204
+ return f"{minutes:02d}:{secs:02d}"
205
+
206
+ def format_final_notes(
207
+ self,
208
+ notes: str,
209
+ video_title: str,
210
+ video_url: str,
211
+ duration: int
212
+ ) -> str:
213
+ """
214
+ Format final notes with metadata.
215
+
216
+ Args:
217
+ notes: Generated notes
218
+ video_title: Video title
219
+ video_url: Original YouTube URL
220
+ duration: Video duration in seconds
221
+
222
+ Returns:
223
+ Formatted notes with metadata header
224
+ """
225
+ duration_str = self._format_timestamp(duration)
226
+
227
+ header = f"""# {video_title}
228
+
229
+ ---
230
+
231
+ **Source:** [{video_url}]({video_url})
232
+ **Duration:** {duration_str}
233
+ **Generated:** AI Study Notes
234
+
235
+ ---
236
+
237
+ """
238
+
239
+ return header + notes
src/summarization/segmenter.py ADDED
@@ -0,0 +1,173 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Transcript segmentation module.
3
+ Splits long transcripts into logical sections for better processing.
4
+ """
5
+
6
+ import re
7
+ from typing import List, Dict
8
+
9
+ from src.utils.logger import setup_logger
10
+
11
+ logger = setup_logger(__name__)
12
+
13
+
14
+ class TranscriptSegmenter:
15
+ """Handles intelligent segmentation of transcripts."""
16
+
17
+ # Common filler words to remove
18
+ FILLER_WORDS = {
19
+ 'um', 'uh', 'like', 'you know', 'i mean', 'sort of', 'kind of',
20
+ 'basically', 'actually', 'literally', 'right', 'okay', 'so yeah'
21
+ }
22
+
23
+ def __init__(self, max_segment_words: int = 500):
24
+ """
25
+ Initialize the segmenter.
26
+
27
+ Args:
28
+ max_segment_words: Maximum words per segment
29
+ """
30
+ self.max_segment_words = max_segment_words
31
+
32
+ def clean_text(self, text: str) -> str:
33
+ """
34
+ Clean transcript by removing filler words and normalizing.
35
+
36
+ Args:
37
+ text: Raw transcript text
38
+
39
+ Returns:
40
+ Cleaned text
41
+ """
42
+ # Convert to lowercase for processing
43
+ cleaned = text.lower()
44
+
45
+ # Remove filler words
46
+ for filler in self.FILLER_WORDS:
47
+ # Use word boundaries to avoid partial matches
48
+ pattern = r'\b' + re.escape(filler) + r'\b'
49
+ cleaned = re.sub(pattern, '', cleaned, flags=re.IGNORECASE)
50
+
51
+ # Remove multiple spaces
52
+ cleaned = re.sub(r'\s+', ' ', cleaned)
53
+
54
+ # Remove leading/trailing whitespace
55
+ cleaned = cleaned.strip()
56
+
57
+ # Capitalize first letter of sentences
58
+ cleaned = '. '.join(s.capitalize() for s in cleaned.split('. '))
59
+
60
+ logger.debug(f"Cleaned text: reduced from {len(text)} to {len(cleaned)} characters")
61
+
62
+ return cleaned
63
+
64
+ def segment_by_time(
65
+ self,
66
+ segments: List[Dict],
67
+ interval_seconds: int = 300
68
+ ) -> List[Dict]:
69
+ """
70
+ Segment transcript by time intervals.
71
+
72
+ Args:
73
+ segments: List of timestamped segments from Whisper
74
+ interval_seconds: Time interval for each segment (default: 5 minutes)
75
+
76
+ Returns:
77
+ List of combined segments grouped by time
78
+ """
79
+ if not segments:
80
+ return []
81
+
82
+ time_segments = []
83
+ current_segment = {
84
+ 'start': segments[0]['start'],
85
+ 'text': ''
86
+ }
87
+
88
+ for seg in segments:
89
+ # Check if we should start a new time segment
90
+ if seg['start'] - current_segment['start'] >= interval_seconds:
91
+ # Save current segment
92
+ current_segment['end'] = seg['start']
93
+ time_segments.append(current_segment)
94
+
95
+ # Start new segment
96
+ current_segment = {
97
+ 'start': seg['start'],
98
+ 'text': seg['text']
99
+ }
100
+ else:
101
+ # Add to current segment
102
+ current_segment['text'] += ' ' + seg['text']
103
+
104
+ # Add the last segment
105
+ if current_segment['text']:
106
+ current_segment['end'] = segments[-1]['end']
107
+ time_segments.append(current_segment)
108
+
109
+ logger.info(f"Segmented transcript into {len(time_segments)} time-based segments")
110
+
111
+ return time_segments
112
+
113
+ def segment_by_topic(self, text: str) -> List[str]:
114
+ """
115
+ Segment text by detecting topic transitions.
116
+ Simple heuristic: Split on paragraph breaks and large sentences.
117
+
118
+ Args:
119
+ text: Full transcript text
120
+
121
+ Returns:
122
+ List of text segments
123
+ """
124
+ # Split by double newlines (paragraphs)
125
+ paragraphs = [p.strip() for p in text.split('\n\n') if p.strip()]
126
+
127
+ segments = []
128
+ current_segment = []
129
+ current_word_count = 0
130
+
131
+ for para in paragraphs:
132
+ words = para.split()
133
+ word_count = len(words)
134
+
135
+ # If adding this paragraph exceeds max words, start new segment
136
+ if current_word_count + word_count > self.max_segment_words and current_segment:
137
+ segments.append(' '.join(current_segment))
138
+ current_segment = [para]
139
+ current_word_count = word_count
140
+ else:
141
+ current_segment.append(para)
142
+ current_word_count += word_count
143
+
144
+ # Add the last segment
145
+ if current_segment:
146
+ segments.append(' '.join(current_segment))
147
+
148
+ logger.info(f"Segmented text into {len(segments)} topic-based segments")
149
+
150
+ return segments
151
+
152
+ def segment_transcript(
153
+ self,
154
+ transcript_data: Dict,
155
+ method: str = "time"
156
+ ) -> List[Dict]:
157
+ """
158
+ Segment transcript using specified method.
159
+
160
+ Args:
161
+ transcript_data: Full transcript data with text and segments
162
+ method: Segmentation method ("time" or "topic")
163
+
164
+ Returns:
165
+ List of segmented chunks
166
+ """
167
+ if method == "time" and 'segments' in transcript_data:
168
+ # Use timestamped segments
169
+ return self.segment_by_time(transcript_data['segments'])
170
+ else:
171
+ # Use topic-based segmentation on full text
172
+ text_segments = self.segment_by_topic(transcript_data['text'])
173
+ return [{'text': seg} for seg in text_segments]
src/transcription/__init__.py ADDED
File without changes
src/transcription/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (154 Bytes). View file
 
src/transcription/__pycache__/whisper_transcriber.cpython-312.pyc ADDED
Binary file (7.93 kB). View file
 
src/transcription/whisper_transcriber.py ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Whisper-based speech-to-text transcription module.
3
+ Converts audio files to text using OpenAI's Whisper model.
4
+ """
5
+
6
+ from pathlib import Path
7
+ from typing import Dict, List, Optional
8
+ import whisper
9
+ import torch
10
+
11
+ from src.utils.logger import setup_logger
12
+ from src.utils.config import settings
13
+
14
+ logger = setup_logger(__name__)
15
+
16
+
17
+ class WhisperTranscriber:
18
+ """Handles audio transcription using Whisper ASR model."""
19
+
20
+ def __init__(self, model_size: Optional[str] = None):
21
+ """
22
+ Initialize the Whisper transcriber.
23
+
24
+ Args:
25
+ model_size: Whisper model size (tiny, base, small, medium, large)
26
+ Defaults to config setting
27
+ """
28
+ self.model_size = model_size or settings.whisper_model_size
29
+ self.model = None
30
+ self.device = "cuda" if torch.cuda.is_available() else "cpu"
31
+
32
+ logger.info(f"Initializing Whisper transcriber with model: {self.model_size}")
33
+ logger.info(f"Using device: {self.device}")
34
+
35
+ def load_model(self) -> None:
36
+ """Load the Whisper model into memory."""
37
+ if self.model is not None:
38
+ logger.info("Model already loaded")
39
+ return
40
+
41
+ try:
42
+ logger.info(f"Loading Whisper {self.model_size} model...")
43
+ self.model = whisper.load_model(self.model_size, device=self.device)
44
+ logger.info("Model loaded successfully")
45
+ except Exception as e:
46
+ logger.error(f"Failed to load Whisper model: {e}")
47
+ raise RuntimeError(f"Model loading failed: {str(e)}")
48
+
49
+ def transcribe(
50
+ self,
51
+ audio_path: Path,
52
+ language: str = "en",
53
+ verbose: bool = True
54
+ ) -> Dict[str, any]:
55
+ """
56
+ Transcribe audio file to text.
57
+
58
+ Args:
59
+ audio_path: Path to the audio file
60
+ language: Language code (default: "en" for English)
61
+ verbose: Whether to show progress during transcription
62
+
63
+ Returns:
64
+ Dictionary containing:
65
+ - text: Full transcript
66
+ - segments: List of timestamped segments
67
+ - language: Detected/specified language
68
+
69
+ Raises:
70
+ FileNotFoundError: If audio file doesn't exist
71
+ RuntimeError: If transcription fails
72
+ """
73
+ if not audio_path.exists():
74
+ raise FileNotFoundError(f"Audio file not found: {audio_path}")
75
+
76
+ # Load model if not already loaded
77
+ self.load_model()
78
+
79
+ try:
80
+ logger.info(f"Starting transcription of: {audio_path}")
81
+ logger.info(f"Language: {language}")
82
+
83
+ # Transcribe with Whisper
84
+ result = self.model.transcribe(
85
+ str(audio_path),
86
+ language=language,
87
+ verbose=verbose,
88
+ task="transcribe",
89
+ fp16=torch.cuda.is_available() # Use FP16 on GPU for speed
90
+ )
91
+
92
+ # Extract relevant information
93
+ transcript_data = {
94
+ 'text': result['text'].strip(),
95
+ 'segments': self._process_segments(result['segments']),
96
+ 'language': result['language'],
97
+ }
98
+
99
+ logger.info(f"Transcription complete. Length: {len(transcript_data['text'])} characters")
100
+ logger.info(f"Number of segments: {len(transcript_data['segments'])}")
101
+
102
+ return transcript_data
103
+
104
+ except Exception as e:
105
+ logger.error(f"Transcription failed: {e}")
106
+ raise RuntimeError(f"Transcription error: {str(e)}")
107
+
108
+ def _process_segments(self, raw_segments: List[Dict]) -> List[Dict]:
109
+ """
110
+ Process raw Whisper segments into a cleaner format.
111
+
112
+ Args:
113
+ raw_segments: Raw segment data from Whisper
114
+
115
+ Returns:
116
+ List of processed segments with timestamps and text
117
+ """
118
+ processed = []
119
+
120
+ for segment in raw_segments:
121
+ processed.append({
122
+ 'id': segment['id'],
123
+ 'start': segment['start'],
124
+ 'end': segment['end'],
125
+ 'text': segment['text'].strip(),
126
+ })
127
+
128
+ return processed
129
+
130
+ def transcribe_with_timestamps(
131
+ self,
132
+ audio_path: Path,
133
+ language: str = "en"
134
+ ) -> str:
135
+ """
136
+ Transcribe audio and format with timestamps.
137
+
138
+ Args:
139
+ audio_path: Path to the audio file
140
+ language: Language code
141
+
142
+ Returns:
143
+ Formatted transcript with timestamps
144
+ """
145
+ result = self.transcribe(audio_path, language, verbose=False)
146
+
147
+ formatted_lines = []
148
+ for segment in result['segments']:
149
+ timestamp = self._format_timestamp(segment['start'])
150
+ formatted_lines.append(f"[{timestamp}] {segment['text']}")
151
+
152
+ return "\n".join(formatted_lines)
153
+
154
+ @staticmethod
155
+ def _format_timestamp(seconds: float) -> str:
156
+ """
157
+ Format seconds into MM:SS or HH:MM:SS.
158
+
159
+ Args:
160
+ seconds: Time in seconds
161
+
162
+ Returns:
163
+ Formatted timestamp string
164
+ """
165
+ hours = int(seconds // 3600)
166
+ minutes = int((seconds % 3600) // 60)
167
+ secs = int(seconds % 60)
168
+
169
+ if hours > 0:
170
+ return f"{hours:02d}:{minutes:02d}:{secs:02d}"
171
+ else:
172
+ return f"{minutes:02d}:{secs:02d}"
173
+
174
+ def get_plain_text(self, audio_path: Path, language: str = "en") -> str:
175
+ """
176
+ Get plain text transcript without timestamps.
177
+
178
+ Args:
179
+ audio_path: Path to the audio file
180
+ language: Language code
181
+
182
+ Returns:
183
+ Plain text transcript
184
+ """
185
+ result = self.transcribe(audio_path, language, verbose=False)
186
+ return result['text']
src/ui/__init__.py ADDED
File without changes
src/ui/app.html ADDED
@@ -0,0 +1,461 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>YouTube Study Notes AI</title>
7
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
8
+ <style>
9
+ * {
10
+ margin: 0;
11
+ padding: 0;
12
+ box-sizing: border-box;
13
+ }
14
+
15
+ body {
16
+ font-family: 'Inter', sans-serif;
17
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
18
+ min-height: 100vh;
19
+ display: flex;
20
+ align-items: center;
21
+ justify-content: center;
22
+ padding: 20px;
23
+ }
24
+
25
+ .container {
26
+ background: rgba(255, 255, 255, 0.95);
27
+ backdrop-filter: blur(10px);
28
+ border-radius: 24px;
29
+ padding: 40px;
30
+ max-width: 800px;
31
+ width: 100%;
32
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
33
+ }
34
+
35
+ .header {
36
+ text-align: center;
37
+ margin-bottom: 40px;
38
+ }
39
+
40
+ .header h1 {
41
+ font-size: 2.5em;
42
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
43
+ -webkit-background-clip: text;
44
+ -webkit-text-fill-color: transparent;
45
+ background-clip: text;
46
+ margin-bottom: 10px;
47
+ }
48
+
49
+ .header p {
50
+ color: #666;
51
+ font-size: 1.1em;
52
+ }
53
+
54
+ .input-section {
55
+ margin-bottom: 30px;
56
+ }
57
+
58
+ .input-group {
59
+ display: flex;
60
+ gap: 10px;
61
+ margin-bottom: 15px;
62
+ }
63
+
64
+ input[type="text"] {
65
+ flex: 1;
66
+ padding: 16px 20px;
67
+ border: 2px solid #e0e0e0;
68
+ border-radius: 12px;
69
+ font-size: 1em;
70
+ font-family: 'Inter', sans-serif;
71
+ transition: all 0.3s ease;
72
+ }
73
+
74
+ input[type="text"]:focus {
75
+ outline: none;
76
+ border-color: #667eea;
77
+ box-shadow: 0 0 0 4px rgba(102, 126, 234, 0.1);
78
+ }
79
+
80
+ .btn {
81
+ padding: 16px 32px;
82
+ border: none;
83
+ border-radius: 12px;
84
+ font-size: 1em;
85
+ font-weight: 600;
86
+ cursor: pointer;
87
+ transition: all 0.3s ease;
88
+ font-family: 'Inter', sans-serif;
89
+ }
90
+
91
+ .btn-primary {
92
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
93
+ color: white;
94
+ }
95
+
96
+ .btn-primary:hover {
97
+ transform: translateY(-2px);
98
+ box-shadow: 0 10px 25px rgba(102, 126, 234, 0.4);
99
+ }
100
+
101
+ .btn-primary:disabled {
102
+ opacity: 0.6;
103
+ cursor: not-allowed;
104
+ transform: none;
105
+ }
106
+
107
+ .status-section {
108
+ background: #f8f9fa;
109
+ border-radius: 16px;
110
+ padding: 20px;
111
+ margin-bottom: 30px;
112
+ display: none;
113
+ }
114
+
115
+ .status-section.active {
116
+ display: block;
117
+ }
118
+
119
+ .status-header {
120
+ display: flex;
121
+ align-items: center;
122
+ gap: 12px;
123
+ margin-bottom: 15px;
124
+ }
125
+
126
+ .status-icon {
127
+ width: 24px;
128
+ height: 24px;
129
+ border-radius: 50%;
130
+ border: 3px solid #667eea;
131
+ border-top-color: transparent;
132
+ animation: spin 1s linear infinite;
133
+ }
134
+
135
+ @keyframes spin {
136
+ to { transform: rotate(360deg); }
137
+ }
138
+
139
+ .status-text {
140
+ font-weight: 600;
141
+ color: #333;
142
+ }
143
+
144
+ .progress-bar {
145
+ width: 100%;
146
+ height: 8px;
147
+ background: #e0e0e0;
148
+ border-radius: 4px;
149
+ overflow: hidden;
150
+ }
151
+
152
+ .progress-fill {
153
+ height: 100%;
154
+ background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
155
+ width: 0%;
156
+ transition: width 0.3s ease;
157
+ }
158
+
159
+ .video-info {
160
+ margin-top: 15px;
161
+ padding-top: 15px;
162
+ border-top: 1px solid #e0e0e0;
163
+ }
164
+
165
+ .video-title {
166
+ font-weight: 600;
167
+ color: #333;
168
+ margin-bottom: 5px;
169
+ }
170
+
171
+ .notes-section {
172
+ display: none;
173
+ }
174
+
175
+ .notes-section.active {
176
+ display: block;
177
+ }
178
+
179
+ .notes-preview {
180
+ background: #ffffff;
181
+ border: 2px solid #e0e0e0;
182
+ border-radius: 16px;
183
+ padding: 30px;
184
+ max-height: 500px;
185
+ overflow-y: auto;
186
+ margin-bottom: 20px;
187
+ }
188
+
189
+ .notes-preview h1 {
190
+ color: #667eea;
191
+ margin-bottom: 20px;
192
+ }
193
+
194
+ .notes-preview h2 {
195
+ color: #764ba2;
196
+ margin-top: 25px;
197
+ margin-bottom: 15px;
198
+ }
199
+
200
+ .notes-preview ul {
201
+ margin-left: 20px;
202
+ margin-bottom: 15px;
203
+ }
204
+
205
+ .notes-preview li {
206
+ margin-bottom: 8px;
207
+ line-height: 1.6;
208
+ }
209
+
210
+ .notes-preview strong {
211
+ color: #667eea;
212
+ }
213
+
214
+ .download-btn {
215
+ width: 100%;
216
+ }
217
+
218
+ .error-message {
219
+ background: #fee;
220
+ border: 2px solid #fcc;
221
+ border-radius: 12px;
222
+ padding: 15px;
223
+ color: #c33;
224
+ margin-bottom: 20px;
225
+ display: none;
226
+ }
227
+
228
+ .error-message.active {
229
+ display: block;
230
+ }
231
+
232
+ .example-links {
233
+ text-align: center;
234
+ margin-top: 20px;
235
+ color: #666;
236
+ font-size: 0.9em;
237
+ }
238
+
239
+ .example-links a {
240
+ color: #667eea;
241
+ text-decoration: none;
242
+ font-weight: 500;
243
+ }
244
+
245
+ .example-links a:hover {
246
+ text-decoration: underline;
247
+ }
248
+ </style>
249
+ </head>
250
+ <body>
251
+ <div class="container">
252
+ <div class="header">
253
+ <h1>📚 YouTube Study Notes AI</h1>
254
+ <p>Transform educational videos into structured study notes</p>
255
+ </div>
256
+
257
+ <div class="error-message" id="errorMessage"></div>
258
+
259
+ <div class="input-section">
260
+ <div class="input-group">
261
+ <input
262
+ type="text"
263
+ id="youtubeUrl"
264
+ placeholder="Paste YouTube video URL here..."
265
+ value=""
266
+ >
267
+ <button class="btn btn-primary" id="generateBtn" onclick="generateNotes()">
268
+ Generate Notes
269
+ </button>
270
+ </div>
271
+ <div class="example-links">
272
+ Try with a short educational video for best results
273
+ </div>
274
+ </div>
275
+
276
+ <div class="status-section" id="statusSection">
277
+ <div class="status-header">
278
+ <div class="status-icon" id="statusIcon"></div>
279
+ <div class="status-text" id="statusText">Processing...</div>
280
+ </div>
281
+ <div class="progress-bar">
282
+ <div class="progress-fill" id="progressFill"></div>
283
+ </div>
284
+ <div class="video-info" id="videoInfo" style="display: none;">
285
+ <div class="video-title" id="videoTitle"></div>
286
+ </div>
287
+ </div>
288
+
289
+ <div class="notes-section" id="notesSection">
290
+ <div class="notes-preview" id="notesPreview"></div>
291
+ <button class="btn btn-primary download-btn" id="downloadBtn" onclick="downloadNotes()">
292
+ Download Notes (Markdown)
293
+ </button>
294
+ </div>
295
+ </div>
296
+
297
+ <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
298
+ <script>
299
+ const API_URL = 'http://localhost:8000';
300
+ let currentTaskId = null;
301
+ let statusCheckInterval = null;
302
+
303
+ function showError(message) {
304
+ const errorEl = document.getElementById('errorMessage');
305
+ errorEl.textContent = message;
306
+ errorEl.classList.add('active');
307
+ }
308
+
309
+ function hideError() {
310
+ document.getElementById('errorMessage').classList.remove('active');
311
+ }
312
+
313
+ function updateStatus(status, progress, message, videoTitle = null) {
314
+ document.getElementById('statusText').textContent = message;
315
+ document.getElementById('progressFill').style.width = progress + '%';
316
+
317
+ if (videoTitle) {
318
+ document.getElementById('videoTitle').textContent = videoTitle;
319
+ document.getElementById('videoInfo').style.display = 'block';
320
+ }
321
+ }
322
+
323
+ async function generateNotes() {
324
+ const url = document.getElementById('youtubeUrl').value.trim();
325
+
326
+ if (!url) {
327
+ showError('Please enter a YouTube URL');
328
+ return;
329
+ }
330
+
331
+ hideError();
332
+
333
+ // Show status section
334
+ document.getElementById('statusSection').classList.add('active');
335
+ document.getElementById('notesSection').classList.remove('active');
336
+
337
+ // Disable button
338
+ const btn = document.getElementById('generateBtn');
339
+ btn.disabled = true;
340
+ btn.textContent = 'Processing...';
341
+
342
+ try {
343
+ // Start note generation
344
+ const response = await fetch(`${API_URL}/generate-notes`, {
345
+ method: 'POST',
346
+ headers: {
347
+ 'Content-Type': 'application/json',
348
+ },
349
+ body: JSON.stringify({
350
+ youtube_url: url,
351
+ language: 'en'
352
+ })
353
+ });
354
+
355
+ if (!response.ok) {
356
+ throw new Error('Failed to start processing');
357
+ }
358
+
359
+ const data = await response.json();
360
+ currentTaskId = data.task_id;
361
+
362
+ // Start checking status
363
+ statusCheckInterval = setInterval(checkStatus, 2000);
364
+
365
+ } catch (error) {
366
+ showError('Error: ' + error.message);
367
+ btn.disabled = false;
368
+ btn.textContent = 'Generate Notes';
369
+ document.getElementById('statusSection').classList.remove('active');
370
+ }
371
+ }
372
+
373
+ async function checkStatus() {
374
+ if (!currentTaskId) return;
375
+
376
+ try {
377
+ const response = await fetch(`${API_URL}/status/${currentTaskId}`);
378
+
379
+ if (!response.ok) {
380
+ throw new Error('Failed to check status');
381
+ }
382
+
383
+ const data = await response.json();
384
+
385
+ updateStatus(
386
+ data.status,
387
+ data.progress || 0,
388
+ data.message,
389
+ data.video_title
390
+ );
391
+
392
+ if (data.status === 'completed') {
393
+ // Stop checking
394
+ clearInterval(statusCheckInterval);
395
+
396
+ // Load and display notes
397
+ await loadNotes();
398
+
399
+ // Reset button
400
+ const btn = document.getElementById('generateBtn');
401
+ btn.disabled = false;
402
+ btn.textContent = 'Generate Notes';
403
+
404
+ } else if (data.status === 'failed') {
405
+ // Stop checking
406
+ clearInterval(statusCheckInterval);
407
+
408
+ showError('Processing failed: ' + data.message);
409
+
410
+ // Reset button
411
+ const btn = document.getElementById('generateBtn');
412
+ btn.disabled = false;
413
+ btn.textContent = 'Generate Notes';
414
+
415
+ document.getElementById('statusSection').classList.remove('active');
416
+ }
417
+
418
+ } catch (error) {
419
+ clearInterval(statusCheckInterval);
420
+ showError('Error checking status: ' + error.message);
421
+ }
422
+ }
423
+
424
+ async function loadNotes() {
425
+ try {
426
+ const response = await fetch(`${API_URL}/download/${currentTaskId}`);
427
+
428
+ if (!response.ok) {
429
+ throw new Error('Failed to load notes');
430
+ }
431
+
432
+ const markdown = await response.text();
433
+
434
+ // Render markdown to HTML
435
+ const html = marked.parse(markdown);
436
+ document.getElementById('notesPreview').innerHTML = html;
437
+
438
+ // Show notes section
439
+ document.getElementById('notesSection').classList.add('active');
440
+ document.getElementById('statusSection').classList.remove('active');
441
+
442
+ } catch (error) {
443
+ showError('Error loading notes: ' + error.message);
444
+ }
445
+ }
446
+
447
+ function downloadNotes() {
448
+ if (!currentTaskId) return;
449
+
450
+ window.open(`${API_URL}/download/${currentTaskId}`, '_blank');
451
+ }
452
+
453
+ // Enter key support
454
+ document.getElementById('youtubeUrl').addEventListener('keypress', (e) => {
455
+ if (e.key === 'Enter') {
456
+ generateNotes();
457
+ }
458
+ });
459
+ </script>
460
+ </body>
461
+ </html>
src/utils/__init__.py ADDED
File without changes
src/utils/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (146 Bytes). View file
 
src/utils/__pycache__/config.cpython-312.pyc ADDED
Binary file (3.42 kB). View file
 
src/utils/__pycache__/logger.cpython-312.pyc ADDED
Binary file (2.48 kB). View file
 
src/utils/config.py ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Configuration management for the YouTube Notes AI application.
3
+ Uses Pydantic Settings for type-safe environment variable loading.
4
+ """
5
+
6
+ import os
7
+ from pathlib import Path
8
+ from typing import Literal
9
+
10
+ from pydantic import Field
11
+ from pydantic_settings import BaseSettings, SettingsConfigDict
12
+
13
+
14
+ class Settings(BaseSettings):
15
+ """Application configuration settings loaded from environment variables."""
16
+
17
+ # Google Gemini API Configuration
18
+ google_api_key: str = Field(
19
+ ...,
20
+ description="Google Gemini API key for note generation"
21
+ )
22
+
23
+ # Whisper Model Configuration
24
+ whisper_model_size: Literal["tiny", "base", "small", "medium", "large"] = Field(
25
+ default="base",
26
+ description="Whisper model size (larger = more accurate but slower)"
27
+ )
28
+
29
+ # Processing Limits
30
+ max_video_duration: int = Field(
31
+ default=7200,
32
+ description="Maximum video duration in seconds (2 hours default)"
33
+ )
34
+
35
+ # Output Configuration
36
+ output_format: Literal["markdown", "json"] = Field(
37
+ default="markdown",
38
+ description="Output format for generated notes"
39
+ )
40
+ output_dir: Path = Field(
41
+ default=Path("outputs"),
42
+ description="Directory for saving generated notes"
43
+ )
44
+
45
+ # Logging Configuration
46
+ log_level: str = Field(
47
+ default="INFO",
48
+ description="Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)"
49
+ )
50
+ log_file: str = Field(
51
+ default="app.log",
52
+ description="Log file path"
53
+ )
54
+
55
+ # API Configuration
56
+ api_host: str = Field(
57
+ default="0.0.0.0",
58
+ description="FastAPI host address"
59
+ )
60
+ api_port: int = Field(
61
+ default=8000,
62
+ description="FastAPI port number"
63
+ )
64
+
65
+ # Database Configuration
66
+ database_url: str = Field(
67
+ default="postgresql+asyncpg://postgres:password@localhost:5432/studynotes",
68
+ description="PostgreSQL database connection URL (use asyncpg driver)"
69
+ )
70
+
71
+ # Authentication Configuration
72
+ secret_key: str = Field(
73
+ default="your-secret-key-change-this-in-production-min-32-chars",
74
+ description="JWT secret key for token signing (MUST be changed in production)"
75
+ )
76
+ access_token_expire_minutes: int = Field(
77
+ default=60,
78
+ description="JWT token expiration time in minutes"
79
+ )
80
+ algorithm: str = Field(
81
+ default="HS256",
82
+ description="JWT signing algorithm"
83
+ )
84
+
85
+ # Temporary Files
86
+ temp_dir: Path = Field(
87
+ default=Path("temp"),
88
+ description="Directory for temporary files (audio, video)"
89
+ )
90
+
91
+ model_config = SettingsConfigDict(
92
+ env_file=".env",
93
+ env_file_encoding="utf-8",
94
+ case_sensitive=False
95
+ )
96
+
97
+ def __init__(self, **kwargs):
98
+ """Initialize settings and create necessary directories."""
99
+ super().__init__(**kwargs)
100
+
101
+ # Create directories if they don't exist
102
+ self.output_dir.mkdir(parents=True, exist_ok=True)
103
+ self.temp_dir.mkdir(parents=True, exist_ok=True)
104
+
105
+
106
+ # Global settings instance
107
+ settings = Settings()