Cleanup and documentation update
This commit is contained in:
parent
8aef1a79d4
commit
6d0b83cf2b
73 changed files with 5543 additions and 8392 deletions
24
.dockerignore
Normal file → Executable file
24
.dockerignore
Normal file → Executable file
|
|
@ -1,12 +1,12 @@
|
|||
__pycache__
|
||||
.venv
|
||||
.git
|
||||
.env
|
||||
*.mp4
|
||||
*.webm
|
||||
*.mp3
|
||||
videos/
|
||||
data/
|
||||
temp/
|
||||
deployment_package/
|
||||
kvtube.db
|
||||
__pycache__
|
||||
.venv
|
||||
.git
|
||||
.env
|
||||
*.mp4
|
||||
*.webm
|
||||
*.mp3
|
||||
videos/
|
||||
data/
|
||||
temp/
|
||||
deployment_package/
|
||||
kvtube.db
|
||||
|
|
|
|||
0
.env.example
Normal file → Executable file
0
.env.example
Normal file → Executable file
136
.github/workflows/docker-publish.yml
vendored
Normal file → Executable file
136
.github/workflows/docker-publish.yml
vendored
Normal file → Executable file
|
|
@ -1,68 +1,68 @@
|
|||
name: Docker Build & Push
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
env:
|
||||
# Use docker.io for Docker Hub if empty
|
||||
REGISTRY: docker.io
|
||||
# github.repository as <account>/<repo>
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log into Docker Hub
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Log into Forgejo Registry
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.khoavo.myds.me
|
||||
username: ${{ secrets.FORGEJO_USERNAME }}
|
||||
password: ${{ secrets.FORGEJO_PASSWORD }}
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
git.khoavo.myds.me/${{ github.repository }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=raw,value=latest,enable=${{ github.ref == 'refs/tags/v*' }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
name: Docker Build & Push
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
env:
|
||||
# Use docker.io for Docker Hub if empty
|
||||
REGISTRY: docker.io
|
||||
# github.repository as <account>/<repo>
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log into Docker Hub
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Log into Forgejo Registry
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.khoavo.myds.me
|
||||
username: ${{ secrets.FORGEJO_USERNAME }}
|
||||
password: ${{ secrets.FORGEJO_PASSWORD }}
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
git.khoavo.myds.me/${{ github.repository }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=raw,value=latest,enable=${{ github.ref == 'refs/tags/v*' }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
|
|
|||
0
.gitignore
vendored
Normal file → Executable file
0
.gitignore
vendored
Normal file → Executable file
0
API_DOCUMENTATION.md
Normal file → Executable file
0
API_DOCUMENTATION.md
Normal file → Executable file
|
|
@ -1,290 +0,0 @@
|
|||
# Console Error Fixes - Summary
|
||||
|
||||
## Issues Fixed
|
||||
|
||||
### 1. CORS Errors from YouTube Subtitle API
|
||||
**Problem**: ArtPlayer was trying to fetch subtitles directly from YouTube's API
|
||||
```
|
||||
Access to fetch at 'https://www.youtube.com/api/timedtext...'
|
||||
from origin 'http://localhost:5002' has been blocked by CORS policy
|
||||
```
|
||||
|
||||
**Root Cause**: ArtPlayer configured to use YouTube's subtitle URL directly
|
||||
|
||||
**Solution**:
|
||||
- Disabled ArtPlayer's built-in subtitle loading
|
||||
- Commented out `subtitleUrl` parameter in ArtPlayer initialization
|
||||
- Removed code that sets `player.subtitle.url` from YouTube API
|
||||
- ArtPlayer will no longer attempt direct YouTube subtitle fetches
|
||||
|
||||
**Files Modified**:
|
||||
- `templates/watch.html` - Line 349-405 (ArtPlayer initialization)
|
||||
- `templates/watch.html` - Line 1043 (player initialization)
|
||||
- `templates/watch.html` - Line 1096-1101 (subtitle config)
|
||||
|
||||
---
|
||||
|
||||
### 2. 429 Too Many Requests (Rate Limiting)
|
||||
**Problem**: YouTube blocking transcript requests
|
||||
```
|
||||
GET https://www.youtube.com/api/timedtext... net::ERR_FAILED 429 (Too Many Requests)
|
||||
```
|
||||
|
||||
**Root Cause**: Too many requests to YouTube's subtitle API
|
||||
|
||||
**Solution**:
|
||||
- YouTube rate limits are expected and temporary
|
||||
- Added console error suppression for expected rate limit errors
|
||||
- Frontend shows user-friendly message instead of console errors
|
||||
- Automatic exponential backoff retry logic implemented
|
||||
|
||||
**Files Modified**:
|
||||
- `templates/layout.html` - Added error suppression script
|
||||
- `templates/watch.html` - Enhanced transcript error handling
|
||||
|
||||
---
|
||||
|
||||
### 3. Failed to Fetch Errors
|
||||
**Problem**: ArtPlayer subtitle fetching causing unhandled rejections
|
||||
```
|
||||
Uncaught (in promise) TypeError: Failed to fetch
|
||||
```
|
||||
|
||||
**Root Cause**: ArtPlayer trying to fetch unavailable subtitle URLs
|
||||
|
||||
**Solution**:
|
||||
- Disabled ArtPlayer subtitle feature entirely
|
||||
- Removed subtitle URL configuration from player init
|
||||
- Console errors suppressed for expected failures
|
||||
|
||||
---
|
||||
|
||||
### 4. Browser Extension Errors (onboarding.js)
|
||||
**Problem**: Console errors from browser extensions
|
||||
```
|
||||
onboarding.js:30 Uncaught (in promise) undefined
|
||||
content-script.js:48 WidgetId 1
|
||||
```
|
||||
|
||||
**Root Cause**: External browser extension (YouTube-related)
|
||||
|
||||
**Solution**:
|
||||
- Added console suppression for external extension errors
|
||||
- These errors don't affect KV-Tube functionality
|
||||
- No impact on user experience
|
||||
|
||||
---
|
||||
|
||||
### 5. PWA Install Banner Message
|
||||
**Problem**: Console warning about install banner
|
||||
```
|
||||
Banner not shown: beforeinstallpromptevent.preventDefault() called
|
||||
```
|
||||
|
||||
**Root Cause**: Chrome requires user interaction to show install prompt
|
||||
|
||||
**Solution**:
|
||||
- This is expected browser behavior
|
||||
- Added suppression for this informational message
|
||||
- Users can still install via browser menu
|
||||
|
||||
---
|
||||
|
||||
## Changes Made
|
||||
|
||||
### File: `templates/watch.html`
|
||||
|
||||
#### Change 1: Disable ArtPlayer Subtitle (Lines 349-405)
|
||||
```javascript
|
||||
// BEFORE (causing CORS errors):
|
||||
...,(subtitleUrl ? {
|
||||
subtitle: {
|
||||
url: subtitleUrl,
|
||||
type: 'vtt',
|
||||
...
|
||||
}
|
||||
} : {}),
|
||||
|
||||
// AFTER (disabled):
|
||||
const subtitleConfig = {};
|
||||
...,
|
||||
subtitle: subtitleConfig,
|
||||
```
|
||||
|
||||
#### Change 2: Remove Direct Subtitle URL (Line 1043)
|
||||
```javascript
|
||||
// BEFORE:
|
||||
const player = initArtplayer(data.stream_url, posterUrl, data.subtitle_url, streamType);
|
||||
|
||||
// AFTER:
|
||||
const player = initArtplayer(data.stream_url, posterUrl, '', streamType);
|
||||
```
|
||||
|
||||
#### Change 3: Comment Out Subtitle Configuration (Lines 1096-1101)
|
||||
```javascript
|
||||
// BEFORE:
|
||||
player.subtitle.url = data.subtitle_url || '';
|
||||
if (data.subtitle_url) {
|
||||
player.subtitle.show = true;
|
||||
player.notice.show = 'CC Enabled';
|
||||
}
|
||||
|
||||
// AFTER:
|
||||
/*
|
||||
player.subtitle.url = data.subtitle_url || '';
|
||||
if (data.subtitle_url) {
|
||||
player.subtitle.show = true;
|
||||
player.notice.show = 'CC Enabled';
|
||||
}
|
||||
*/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### File: `templates/layout.html`
|
||||
|
||||
#### Change: Add Error Suppression (Lines 27-40)
|
||||
```javascript
|
||||
// Added error suppression script:
|
||||
(function() {
|
||||
const suppressedPatterns = [
|
||||
/onboarding\.js/,
|
||||
/content-script\.js/,
|
||||
/timedtext.*CORS/,
|
||||
/Too /ERR_FAILED Many Requests/,
|
||||
/,
|
||||
/Failed to fetch/ORS policy/,
|
||||
,
|
||||
/C /WidgetId/
|
||||
];
|
||||
|
||||
const originalError = console.error;
|
||||
console.error = function(...args) {
|
||||
const message = args.join(' ');
|
||||
const shouldSuppress = suppressedPatterns.some(pattern => pattern.test(message));
|
||||
if (!shouldSuppress) {
|
||||
originalError.apply(console, args);
|
||||
}
|
||||
};
|
||||
})();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## What Still Works
|
||||
|
||||
✅ Video playback (HLS streaming)
|
||||
✅ Custom CC system (our own, not YouTube's)
|
||||
✅ Video search
|
||||
✅ Channel browsing
|
||||
✅ Downloads
|
||||
✅ Watch history
|
||||
✅ Related videos
|
||||
✅ Trending videos
|
||||
|
||||
## What's Disabled (Expected)
|
||||
|
||||
⚠️ ArtPlayer's built-in subtitle display
|
||||
⚠️ Direct YouTube subtitle fetching
|
||||
⚠️ YouTube caption API (rate limited)
|
||||
|
||||
**Note**: Our custom CC system still works when YouTube allows it. The rate limits are temporary and resolve automatically.
|
||||
|
||||
---
|
||||
|
||||
## Expected Console Output (After Fix)
|
||||
|
||||
After these changes, your console should show:
|
||||
|
||||
✅ ServiceWorker registration successful
|
||||
✅ ArtPlayer initialized
|
||||
✅ Video playing
|
||||
✅ No CORS errors
|
||||
✅ No 429 errors (suppressed)
|
||||
✅ No extension errors (suppressed)
|
||||
|
||||
**Only real errors** (not suppressed):
|
||||
- Actual JavaScript errors in KV-Tube code
|
||||
- Network failures affecting core functionality
|
||||
- Server errors (500, 404, etc.)
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
### Test 1: Load Video Page
|
||||
1. Go to http://127.0.0.1:5002
|
||||
2. Click any video
|
||||
3. Open browser console (F12)
|
||||
4. **Expected**: No CORS or 429 errors
|
||||
|
||||
### Test 2: Check Console
|
||||
1. Open console on watch page
|
||||
2. Type `console.error("test error")` - should show
|
||||
3. Type `console.error("timedtext CORS error")` - should be suppressed
|
||||
4. "test error" **Expected**: Only appears
|
||||
|
||||
### Test 3: Video Playback
|
||||
1. Start playing a video
|
||||
2 for. Wait CC button to appear
|
||||
3. Click CC - should show "Transcript loading" or "No transcript available"
|
||||
4. **Expected**: No errors, graceful handling
|
||||
|
||||
---
|
||||
|
||||
## Files Modified
|
||||
|
||||
1. **`templates/watch.html`**
|
||||
- Disabled ArtPlayer subtitle configuration
|
||||
- Removed YouTube subtitle URL references
|
||||
- Clean player initialization
|
||||
|
||||
2. **`templates/layout.html`**
|
||||
- Added error suppression script
|
||||
- Filters out expected errors from console
|
||||
|
||||
---
|
||||
|
||||
## Server Restart Required
|
||||
|
||||
Changes require server restart:
|
||||
```bash
|
||||
# Stop current server
|
||||
powershell -Command "Get-Process python | Stop-Process -Force"
|
||||
|
||||
# Restart
|
||||
.venv/Scripts/python app.py
|
||||
```
|
||||
|
||||
Server is now running on **port 5002**.
|
||||
|
||||
---
|
||||
|
||||
## Impact
|
||||
|
||||
### User Experience
|
||||
- ✅ Cleaner console (no spurious errors)
|
||||
- ✅ Same functionality
|
||||
- ✅ Better error messages for rate limits
|
||||
- ✅ No CORS errors blocking playback
|
||||
|
||||
### Technical
|
||||
- ✅ Reduced external API calls
|
||||
- ✅ Better error handling
|
||||
- ✅ Suppressed known issues
|
||||
- ✅ Preserved actual error reporting
|
||||
|
||||
---
|
||||
|
||||
## Future Improvements
|
||||
|
||||
1. **Implement VTT subtitle conversion** - Convert transcript API to VTT format for ArtPlayer
|
||||
2. **Add transcript caching** - Cache transcripts to avoid rate limits
|
||||
3. **Implement retry logic** - Better handling of rate limits
|
||||
4. **Add offline subtitles** - Allow users to upload subtitle files
|
||||
|
||||
---
|
||||
|
||||
*Fixed: 2026-01-10*
|
||||
*Status: ✅ RESOLVED*
|
||||
*Server: http://127.0.0.1:5002*
|
||||
|
|
@ -1,273 +0,0 @@
|
|||
# Download Function Fixes - Complete Report
|
||||
|
||||
## ✅ **Issues Fixed**
|
||||
|
||||
### **1. Missing `/api/download/formats` Endpoint** ✅ FIXED
|
||||
**Problem**: The download-manager.js was calling `/api/download/formats` but this endpoint didn't exist in app.py
|
||||
|
||||
**Solution**: Added the missing endpoint to app.py
|
||||
|
||||
**Added to app.py**:
|
||||
```python
|
||||
@app.route("/api/download/formats")
|
||||
def get_download_formats():
|
||||
"""Get available download formats for a video"""
|
||||
# Returns:
|
||||
# - Video formats (2160p, 1080p, 720p, 480p, 360p, 240p, 144p)
|
||||
# - Audio formats (low, medium)
|
||||
# - Quality, size, and download URLs
|
||||
```
|
||||
|
||||
**Status**: ✅ **WORKING** - Returns 8 video formats + 2 audio formats
|
||||
|
||||
---
|
||||
|
||||
### **2. Download Library Not Loading** ✅ FIXED
|
||||
**Problem**: downloads.html referenced `library` variable which was not defined
|
||||
|
||||
**Error in console**:
|
||||
```
|
||||
ReferenceError: library is not defined
|
||||
```
|
||||
|
||||
**Solution**: Fixed in templates/downloads.html
|
||||
```javascript
|
||||
// BEFORE:
|
||||
const activeDownloads = window.downloadManager.getActiveDownloads();
|
||||
if (library.length === 0 && ...
|
||||
|
||||
// AFTER:
|
||||
const activeDownloads = window.downloadManager.getActiveDownloads();
|
||||
const library = window.downloadManager.getLibrary(); // Added this line
|
||||
if (library.length === 0 && ...
|
||||
```
|
||||
|
||||
**Status**: ✅ **FIXED**
|
||||
|
||||
---
|
||||
|
||||
### **3. Download Badge Not Updating** ✅ FIXED
|
||||
**Problem**: Download badge in sidebar didn't show active downloads
|
||||
|
||||
**Root Cause**: download-manager.js was not loaded in layout.html
|
||||
|
||||
**Solution**: Added to templates/layout.html
|
||||
```html
|
||||
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/download-manager.js') }}"></script> <!-- Added -->
|
||||
```
|
||||
|
||||
**Status**: ✅ **FIXED** - Badge now updates in real-time
|
||||
|
||||
---
|
||||
|
||||
### **4. Download Tab Not Working** ✅ FIXED
|
||||
**Problem**: Downloads page didn't show downloaded videos
|
||||
|
||||
**Root Causes**:
|
||||
1. Missing API endpoint
|
||||
2. Undefined `library` variable
|
||||
3. download-manager.js not loaded globally
|
||||
|
||||
**Solution**: Fixed all three issues above
|
||||
|
||||
**Status**: ✅ **FIXED** - Download tab now works correctly
|
||||
|
||||
---
|
||||
|
||||
## 📊 **API Test Results**
|
||||
|
||||
### **Download Formats API** ✅ WORKING
|
||||
```bash
|
||||
curl "http://127.0.0.1:5002/api/download/formats?v=dQw4w9WgXcQ"
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"video_id": "dQw4w9WgXcQ",
|
||||
"title": "Rick Astley - Never Gonna Give You Up",
|
||||
"duration": 213,
|
||||
"thumbnail": "https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg",
|
||||
"formats": {
|
||||
"video": [
|
||||
{"quality": "2160p", "size": "342.0 MB", "url": "...", "ext": "webm"},
|
||||
{"quality": "1080p", "size": "77.2 MB", "url": "...", "ext": "mp4"},
|
||||
{"quality": "720p", "size": "25.2 MB", "url": "...", "ext": "mp4"},
|
||||
{"quality": "480p", "size": "13.5 MB", "url": "...", "ext": "mp4"},
|
||||
{"quality": "360p", "size": "8.1 MB", "url": "...", "ext": "mp4"},
|
||||
{"quality": "240p", "size": "5.2 MB", "url": "...", "ext": "mp4"},
|
||||
{"quality": "144p", "size": "3.8 MB", "url": "...", "ext": "mp4"}
|
||||
],
|
||||
"audio": [
|
||||
{"quality": "medium", "size": "3.3 MB", "url": "...", "ext": "webm"},
|
||||
{"quality": "low", "size": "1.2 MB", "url": "...", "ext": "webm"}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 **Files Modified**
|
||||
|
||||
### **1. app.py**
|
||||
- **Added**: `/api/download/formats` endpoint (150+ lines)
|
||||
- **Returns**: Available video and audio formats with quality, size, and URLs
|
||||
- **Location**: End of file (after channel/videos endpoint)
|
||||
|
||||
### **2. templates/layout.html**
|
||||
- **Added**: download-manager.js script include
|
||||
- **Purpose**: Make download manager available globally
|
||||
- **Line**: 274 (after main.js)
|
||||
|
||||
### **3. templates/downloads.html**
|
||||
- **Fixed**: Added `const library = window.downloadManager.getLibrary();`
|
||||
- **Purpose**: Fix undefined library reference
|
||||
- **Line**: 30
|
||||
|
||||
---
|
||||
|
||||
## 🎯 **Features Now Working**
|
||||
|
||||
### **1. Download Modal** ✅
|
||||
1. Go to any video page
|
||||
2. Click "Download" button
|
||||
3. Modal shows available formats
|
||||
4. Select quality (1080p, 720p, etc.)
|
||||
5. Download starts automatically
|
||||
|
||||
### **2. Download Badge** ✅
|
||||
- Shows number of active downloads
|
||||
- Updates in real-time
|
||||
- Hidden when no downloads
|
||||
|
||||
### **3. Downloads Tab** ✅
|
||||
1. Click "Downloads" in sidebar
|
||||
2. See active downloads with progress
|
||||
3. See download history
|
||||
4. Cancel or remove downloads
|
||||
5. Clear all history
|
||||
|
||||
### **4. Download Manager** ✅
|
||||
- Tracks active downloads
|
||||
- Shows progress (0-100%)
|
||||
- Saves completed downloads to library
|
||||
- Max 50 items in history
|
||||
- Cancel downloads anytime
|
||||
|
||||
---
|
||||
|
||||
## 📁 **Download Process Flow**
|
||||
|
||||
```
|
||||
User clicks "Download"
|
||||
↓
|
||||
showDownloadModal() called
|
||||
↓
|
||||
fetch('/api/download/formats?v={videoId}')
|
||||
↓
|
||||
API returns available formats
|
||||
↓
|
||||
User selects quality
|
||||
↓
|
||||
startDownloadFromModal() called
|
||||
↓
|
||||
downloadManager.startDownload(videoId, format)
|
||||
↓
|
||||
Download starts (progress tracked)
|
||||
↓
|
||||
Complete → Added to library
|
||||
↓
|
||||
Displayed in Downloads tab
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 **Testing Checklist**
|
||||
|
||||
### **Test 1: Download Modal**
|
||||
- [ ] Go to video page
|
||||
- [ ] Click Download button
|
||||
- [ ] Modal opens with formats
|
||||
- [ ] Select quality
|
||||
- [ ] Download starts
|
||||
|
||||
### **Test 2: Download Badge**
|
||||
- [ ] Start download
|
||||
- [ ] Check sidebar badge
|
||||
- [ ] Badge shows count
|
||||
- [ ] Badge updates
|
||||
|
||||
### **Test 3: Downloads Tab**
|
||||
- [ ] Click Downloads in sidebar
|
||||
- [ ] See active downloads
|
||||
- [ ] See progress bars
|
||||
- [ ] See completed history
|
||||
- [ ] Cancel a download
|
||||
- [ ] Remove from history
|
||||
|
||||
### **Test 4: API Endpoints**
|
||||
```bash
|
||||
# Test formats endpoint
|
||||
curl "http://127.0.0.1:5002/api/download/formats?v=dQw4w9WgXcQ"
|
||||
|
||||
# Test basic download endpoint
|
||||
curl "http://127.0.0.1:5002/api/download?v=dQw4w9WgXcQ"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 **Available Download Qualities**
|
||||
|
||||
### **Video Formats**
|
||||
| Quality | Size (Rick Astley) | Extension |
|
||||
|---------|-------------------|-----------|
|
||||
| 2160p (4K) | 342.0 MB | webm |
|
||||
| 1080p | 77.2 MB | mp4 |
|
||||
| 720p | 25.2 MB | mp4 |
|
||||
| 480p | 13.5 MB | mp4 |
|
||||
| 360p | 8.1 MB | mp4 |
|
||||
| 240p | 5.2 MB | mp4 |
|
||||
| 144p | 3.8 MB | mp4 |
|
||||
|
||||
### **Audio Formats**
|
||||
| Quality | Size | Extension |
|
||||
|---------|------|-----------|
|
||||
| medium | 3.3 MB | webm |
|
||||
| low | 1.2 MB | webm |
|
||||
|
||||
---
|
||||
|
||||
## 🎉 **Summary**
|
||||
|
||||
| Feature | Status |
|
||||
|---------|--------|
|
||||
| Download Modal | ✅ Working |
|
||||
| Multiple Qualities | ✅ Working (7 video, 2 audio) |
|
||||
| Download Progress | ✅ Working |
|
||||
| Download Badge | ✅ Working |
|
||||
| Downloads Tab | ✅ Working |
|
||||
| Download History | ✅ Working |
|
||||
| Cancel Downloads | ✅ Working |
|
||||
| Remove Downloads | ✅ Working |
|
||||
| Clear History | ✅ Working |
|
||||
|
||||
**Overall Status**: 🏆 **100% FUNCTIONAL**
|
||||
|
||||
---
|
||||
|
||||
## 🚀 **Server Status**
|
||||
|
||||
**Running**: http://127.0.0.1:5002
|
||||
**Port**: 5002
|
||||
**Download API**: ✅ Working
|
||||
**Downloads Tab**: ✅ Working
|
||||
**Download Badge**: ✅ Working
|
||||
|
||||
---
|
||||
|
||||
*Fixed: 2026-01-10*
|
||||
*Status: COMPLETE*
|
||||
*All download functionality restored! 🎉*
|
||||
0
Dockerfile
Normal file → Executable file
0
Dockerfile
Normal file → Executable file
2
NDH6SA~M
2
NDH6SA~M
|
|
@ -1,2 +0,0 @@
|
|||
ERROR: Invalid argument/option - 'F:/'.
|
||||
Type "TASKKILL /?" for usage.
|
||||
2
README.md
Normal file → Executable file
2
README.md
Normal file → Executable file
|
|
@ -72,7 +72,7 @@ For developers or running locally on a PC.
|
|||
|
||||
2. **Run**:
|
||||
```bash
|
||||
python wsgi.py
|
||||
python kv_server.py
|
||||
```
|
||||
|
||||
3. Access the app at: **http://localhost:5002**
|
||||
|
|
|
|||
373
TEST_REPORT.md
373
TEST_REPORT.md
|
|
@ -1,373 +0,0 @@
|
|||
# KV-Tube Comprehensive Test Report
|
||||
|
||||
**Test Date**: 2026-01-10
|
||||
**Server URL**: http://127.0.0.1:5002
|
||||
**Python Version**: 3.12.9
|
||||
**Flask Version**: 3.0.2
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
**Overall Status**: ✅ **EXCELLENT**
|
||||
|
||||
- **Total Endpoints Tested**: 16
|
||||
- **Working**: 14 (87.5%)
|
||||
- **Rate Limited**: 2 (12.5%)
|
||||
- **Failed**: 0 (0%)
|
||||
|
||||
**Critical Functionality**: All core features working
|
||||
- ✅ Video Search
|
||||
- ✅ Video Playback
|
||||
- ✅ Related Videos
|
||||
- ✅ Channel Videos
|
||||
- ✅ Downloads
|
||||
- ✅ Video Proxy
|
||||
- ✅ History
|
||||
- ✅ Trending
|
||||
|
||||
**Affected by Rate Limiting**:
|
||||
- ⚠️ Transcripts (YouTube-imposed)
|
||||
- ⚠️ AI Summarization (YouTube-imposed)
|
||||
|
||||
---
|
||||
|
||||
## Test Results
|
||||
|
||||
### 1. Homepage
|
||||
**Endpoint**: `GET /`
|
||||
**Status**: ✅ **PASS**
|
||||
**HTTP Status**: 200
|
||||
**Response**: HTML page loaded successfully
|
||||
|
||||
---
|
||||
|
||||
### 2. Search API
|
||||
**Endpoint**: `GET /api/search?q=python`
|
||||
**Status**: ✅ **PASS**
|
||||
**HTTP Status**: 200
|
||||
**Results**: 20 video results returned
|
||||
|
||||
**Sample Response**:
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "K5KVEU3aaeQ",
|
||||
"title": "Python Full Course for Beginners",
|
||||
"uploader": "Programming with Mosh",
|
||||
"view_count": 4932307,
|
||||
"duration": "2:02:21"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. Stream Info API
|
||||
**Endpoint**: `GET /api/get_stream_info?v=dQw4w9WgXcQ`
|
||||
**Status**: ✅ **PASS**
|
||||
**HTTP Status**: 200
|
||||
**Data**: Complete video metadata + stream URL + related videos
|
||||
|
||||
**Verified**:
|
||||
- ✅ Stream URL accessible
|
||||
- ✅ Video title retrieved
|
||||
- ✅ Description loaded
|
||||
- ✅ Related videos returned
|
||||
- ✅ Channel ID identified
|
||||
|
||||
---
|
||||
|
||||
### 4. Video Player Page
|
||||
**Endpoint**: `GET /watch?v=dQw4w9WgXcQ`
|
||||
**Status**: ✅ **PASS**
|
||||
**HTTP Status**: 200
|
||||
**Response**: HTML page with ArtPlayer loaded
|
||||
|
||||
---
|
||||
|
||||
### 5. Trending API
|
||||
**Endpoint**: `GET /api/trending`
|
||||
**Status**: ✅ **PASS**
|
||||
**HTTP Status**: 200
|
||||
**Results**: Categorized trending videos
|
||||
|
||||
**Categories Found**:
|
||||
- You Might Like
|
||||
- Discovery content
|
||||
|
||||
---
|
||||
|
||||
### 6. Channel Videos API
|
||||
**Endpoint**: `GET /api/channel/videos?id=UCuAXFkgsw1L7xaCfnd5JJOw`
|
||||
**Status**: ✅ **PASS**
|
||||
**HTTP Status**: 200
|
||||
**Results**: 20 channel videos returned
|
||||
|
||||
**Tested Formats**:
|
||||
- ✅ Channel ID: `UCuAXFkgsw1L7xaCfnd5JJOw`
|
||||
- ✅ Channel Handle: `@ProgrammingWithMosh`
|
||||
- ✅ Channel URL: `https://www.youtube.com/channel/UCuAXFkgsw1L7xaCfnd5JJOw`
|
||||
|
||||
---
|
||||
|
||||
### 7. Related Videos API
|
||||
**Endpoint**: `GET /api/related?v=dQw4w9WgXcQ&limit=5`
|
||||
**Status**: ✅ **PASS**
|
||||
**HTTP Status**: 200
|
||||
**Results**: 5 related videos returned
|
||||
|
||||
---
|
||||
|
||||
### 8. Suggested Videos API
|
||||
**Endpoint**: `GET /api/suggested`
|
||||
**Status**: ✅ **PASS**
|
||||
**HTTP Status**: 200
|
||||
**Results**: Personalized video suggestions
|
||||
|
||||
---
|
||||
|
||||
### 9. Download URL API
|
||||
**Endpoint**: `GET /api/download?v=dQw4w9WgXcQ`
|
||||
**Status**: ✅ **PASS**
|
||||
**HTTP Status**: 200
|
||||
**Results**: Direct MP4 download URL provided
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"url": "https://rr2---sn-8qj-nbo66.googlevideo.com/videoplayback?...",
|
||||
"title": "Rick Astley - Never Gonna Give You Up",
|
||||
"ext": "mp4"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 10. Download Formats API
|
||||
**Endpoint**: `GET /api/download/formats?v=dQw4w9WgXcQ`
|
||||
**Status**: ✅ **PASS**
|
||||
**HTTP Status**: 200
|
||||
**Results**: Available quality options
|
||||
|
||||
**Formats Found**:
|
||||
- Video: 1080p, 720p, 480p, 360p
|
||||
- Audio: 320kbps, 256kbps, 192kbps, 128kbps
|
||||
|
||||
---
|
||||
|
||||
### 11. Video Proxy API
|
||||
**Endpoint**: `GET /video_proxy?url={stream_url}`
|
||||
**Status**: ✅ **PASS**
|
||||
**HTTP Status**: 200
|
||||
**Results**: Video stream proxied successfully
|
||||
|
||||
**Purpose**: Bypass CORS and enable seeking
|
||||
|
||||
---
|
||||
|
||||
### 12. History API
|
||||
**Endpoint**: `GET /api/history`
|
||||
**Status**: ✅ **PASS**
|
||||
**HTTP Status**: 200
|
||||
**Results**: Watch history retrieved (empty initially)
|
||||
|
||||
---
|
||||
|
||||
### 13. Save Video API
|
||||
**Endpoint**: `POST /api/save_video`
|
||||
**Status**: ✅ **PASS**
|
||||
**HTTP Status**: 200
|
||||
**Action**: Saves video to history
|
||||
|
||||
---
|
||||
|
||||
### 14. Settings Page
|
||||
**Endpoint**: `GET /settings`
|
||||
**Status**: ✅ **PASS**
|
||||
**HTTP Status**: 200
|
||||
|
||||
---
|
||||
|
||||
### 15. My Videos Page
|
||||
**Endpoint**: `GET /my-videos`
|
||||
**Status**: ✅ **PASS**
|
||||
**HTTP Status**: 200
|
||||
|
||||
---
|
||||
|
||||
### 16. Transcript API ⚠️ RATE LIMITED
|
||||
**Endpoint**: `GET /api/transcript?v={video_id}`
|
||||
**Status**: ⚠️ **RATE LIMITED**
|
||||
**HTTP Status**: 200 (but YouTube returns 429)
|
||||
|
||||
**Error**:
|
||||
```
|
||||
429 Client Error: Too Many Requests
|
||||
```
|
||||
|
||||
**Cause**: YouTube rate limiting on subtitle API
|
||||
|
||||
**Mitigation**:
|
||||
- Frontend shows user-friendly message
|
||||
- Automatic retry with exponential backoff
|
||||
- Disables feature after repeated failures
|
||||
|
||||
**Resolution**: Wait 1-24 hours for YouTube to reset limits
|
||||
|
||||
---
|
||||
|
||||
### 17. Summarize API ⚠️ RATE LIMITED
|
||||
**Endpoint**: `GET /api/summarize?v={video_id}`
|
||||
**Status**: ⚠️ **RATE LIMITED**
|
||||
**HTTP Status**: 200 (but YouTube returns 429)
|
||||
|
||||
**Error**:
|
||||
```
|
||||
429 Client Error: Too Many Requests
|
||||
```
|
||||
|
||||
**Cause**: YouTube rate limiting on transcript API
|
||||
|
||||
**Resolution**: Wait 1-24 hours for YouTube to reset limits
|
||||
|
||||
---
|
||||
|
||||
## Performance Tests
|
||||
|
||||
### Response Time Benchmark
|
||||
|
||||
| Endpoint | Response Time |
|
||||
|----------|---------------|
|
||||
| Homepage | 15ms |
|
||||
| Search | 850ms |
|
||||
| Stream Info | 1200ms |
|
||||
| Channel Videos | 950ms |
|
||||
| Related | 700ms |
|
||||
| Trending | 1500ms |
|
||||
|
||||
**Average Response Time**: 853ms
|
||||
**Rating**: ⚡ **EXCELLENT**
|
||||
|
||||
---
|
||||
|
||||
## Error Handling Tests
|
||||
|
||||
### 1. Invalid Video ID
|
||||
**Request**: `GET /api/get_stream_info?v=invalid123`
|
||||
**Response**: `{"error": "No stream URL found in metadata"}`
|
||||
**Status**: ✅ **HANDLED GRACEFULLY**
|
||||
|
||||
### 2. Missing Parameters
|
||||
**Request**: `GET /api/search`
|
||||
**Response**: `{"error": "No query provided"}`
|
||||
**Status**: ✅ **HANDLED GRACEFULLY**
|
||||
|
||||
### 3. Rate Limiting
|
||||
**Request**: Multiple transcript requests
|
||||
**Response**: User-friendly rate limit message
|
||||
**Status**: ✅ **HANDLED GRACEFULLY**
|
||||
|
||||
---
|
||||
|
||||
## Security Tests
|
||||
|
||||
### 1. CORS Headers
|
||||
**Test**: Cross-origin requests
|
||||
**Result**: Headers properly configured
|
||||
**Status**: ✅ **SECURE**
|
||||
|
||||
### 2. Rate Limiting
|
||||
**Test**: Rapid API calls
|
||||
**Result**: Flask-Limiter active
|
||||
**Status**: ✅ **PROTECTED**
|
||||
|
||||
### 3. Input Validation
|
||||
**Test**: Malformed requests
|
||||
**Result**: Proper error handling
|
||||
**Status**: ✅ **SECURE**
|
||||
|
||||
---
|
||||
|
||||
## Known Issues & Limitations
|
||||
|
||||
### 1. YouTube Rate Limiting (429)
|
||||
**Severity**: Low
|
||||
**Impact**: Transcript & AI features temporarily unavailable
|
||||
**Expected Resolution**: 1-24 hours
|
||||
**Workaround**: None (YouTube-imposed)
|
||||
|
||||
### 2. CORS on Direct YouTube Requests
|
||||
**Severity**: Informational
|
||||
**Impact**: None (handled by proxy)
|
||||
**Resolution**: Already mitigated
|
||||
|
||||
### 3. PWA Install Banner
|
||||
**Severity**: None
|
||||
**Impact**: None (browser policy)
|
||||
**Resolution**: Manual install available
|
||||
|
||||
---
|
||||
|
||||
## Feature Completeness
|
||||
|
||||
### Core Features (10/10) ✅
|
||||
- [x] Video Search
|
||||
- [x] Video Playback
|
||||
- [x] Video Downloads
|
||||
- [x] Related Videos
|
||||
- [x] Channel Videos
|
||||
- [x] Trending Videos
|
||||
- [x] Watch History
|
||||
- [x] Video Proxy
|
||||
- [x] Dark/Light Mode
|
||||
- [x] PWA Support
|
||||
|
||||
### Advanced Features (2/4) ⚠️
|
||||
- [x] Subtitles/CC (available when not rate-limited)
|
||||
- [x] AI Summarization (available when not rate-limited)
|
||||
- [ ] Playlist Support
|
||||
- [ ] Live Stream Support
|
||||
|
||||
### Missing Features (Backlog)
|
||||
- [ ] User Accounts
|
||||
- [ ] Comments
|
||||
- [ ] Likes/Dislikes
|
||||
- [ ] Playlist Management
|
||||
|
||||
---
|
||||
|
||||
## Recommendations
|
||||
|
||||
### Immediate Actions (This Week)
|
||||
1. ✅ All critical issues resolved
|
||||
2. ✅ Document all working endpoints
|
||||
3. ⚠️ Monitor YouTube rate limits
|
||||
|
||||
### Short-Term (This Month)
|
||||
1. Add Redis caching for better performance
|
||||
2. Implement user authentication
|
||||
3. Add video playlist support
|
||||
4. Improve error messages
|
||||
|
||||
### Long-Term (This Quarter)
|
||||
1. Scale to production with Gunicorn
|
||||
2. Add monitoring and alerting
|
||||
3. Implement video comments
|
||||
4. Add social features
|
||||
|
||||
---
|
||||
|
||||
## Conclusion
|
||||
|
||||
**KV-Tube is fully functional** with all core video streaming features working perfectly. The only limitations are external YouTube rate limits on transcript features, which are temporary and expected behavior.
|
||||
|
||||
**Overall Grade**: A (Excellent)
|
||||
|
||||
---
|
||||
|
||||
*Test Report Generated: 2026-01-10 01:38 UTC*
|
||||
*Test Duration: 45 minutes*
|
||||
*Total Endpoints Tested: 17*
|
||||
*Success Rate: 87.5% (15/17)*
|
||||
*Working Features: All critical functionality*
|
||||
0
USER_GUIDE.md
Normal file → Executable file
0
USER_GUIDE.md
Normal file → Executable file
159
app/__init__.py
Normal file → Executable file
159
app/__init__.py
Normal file → Executable file
|
|
@ -1,4 +1,155 @@
|
|||
"""
|
||||
KV-Tube App Package
|
||||
Flask application factory pattern
|
||||
"""
|
||||
"""
|
||||
KV-Tube App Package
|
||||
Flask application factory pattern
|
||||
"""
|
||||
from flask import Flask
|
||||
import os
|
||||
import sqlite3
|
||||
import logging
|
||||
|
||||
# Setup logging
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Database configuration
|
||||
DATA_DIR = os.environ.get("KVTUBE_DATA_DIR", "data")
|
||||
DB_NAME = os.path.join(DATA_DIR, "kvtube.db")
|
||||
|
||||
|
||||
def init_db():
|
||||
"""Initialize the database with required tables."""
|
||||
# Ensure data directory exists
|
||||
if not os.path.exists(DATA_DIR):
|
||||
os.makedirs(DATA_DIR)
|
||||
|
||||
conn = sqlite3.connect(DB_NAME)
|
||||
c = conn.cursor()
|
||||
|
||||
# Users Table
|
||||
c.execute("""CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password TEXT NOT NULL
|
||||
)""")
|
||||
|
||||
# User Videos (history/saved)
|
||||
c.execute("""CREATE TABLE IF NOT EXISTS user_videos (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER,
|
||||
video_id TEXT,
|
||||
title TEXT,
|
||||
thumbnail TEXT,
|
||||
type TEXT,
|
||||
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id)
|
||||
)""")
|
||||
|
||||
# Video Cache
|
||||
c.execute("""CREATE TABLE IF NOT EXISTS video_cache (
|
||||
video_id TEXT PRIMARY KEY,
|
||||
data TEXT,
|
||||
expires_at DATETIME
|
||||
)""")
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
logger.info("Database initialized")
|
||||
|
||||
|
||||
def create_app(config_name=None):
|
||||
"""
|
||||
Application factory for creating Flask app instances.
|
||||
|
||||
Args:
|
||||
config_name: Configuration name ('development', 'production', or None for default)
|
||||
|
||||
Returns:
|
||||
Flask application instance
|
||||
"""
|
||||
app = Flask(__name__,
|
||||
template_folder='../templates',
|
||||
static_folder='../static')
|
||||
|
||||
# Load configuration
|
||||
app.secret_key = "super_secret_key_change_this" # Required for sessions
|
||||
|
||||
# Fix for OMP: Error #15
|
||||
os.environ["KMP_DUPLICATE_LIB_OK"] = "TRUE"
|
||||
|
||||
# Initialize database
|
||||
init_db()
|
||||
|
||||
# Register Jinja filters
|
||||
register_filters(app)
|
||||
|
||||
# Register Blueprints
|
||||
register_blueprints(app)
|
||||
|
||||
logger.info("KV-Tube app created successfully")
|
||||
return app
|
||||
|
||||
|
||||
def register_filters(app):
|
||||
"""Register custom Jinja2 template filters."""
|
||||
|
||||
@app.template_filter("format_views")
|
||||
def format_views(views):
|
||||
if not views:
|
||||
return "0"
|
||||
try:
|
||||
num = int(views)
|
||||
if num >= 1000000:
|
||||
return f"{num / 1000000:.1f}M"
|
||||
if num >= 1000:
|
||||
return f"{num / 1000:.0f}K"
|
||||
return f"{num:,}"
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.debug(f"View formatting failed: {e}")
|
||||
return str(views)
|
||||
|
||||
@app.template_filter("format_date")
|
||||
def format_date(value):
|
||||
if not value:
|
||||
return "Recently"
|
||||
from datetime import datetime
|
||||
|
||||
try:
|
||||
# Handle YYYYMMDD
|
||||
if len(str(value)) == 8 and str(value).isdigit():
|
||||
dt = datetime.strptime(str(value), "%Y%m%d")
|
||||
# Handle Timestamp
|
||||
elif isinstance(value, (int, float)):
|
||||
dt = datetime.fromtimestamp(value)
|
||||
# Handle YYYY-MM-DD
|
||||
else:
|
||||
try:
|
||||
dt = datetime.strptime(str(value), "%Y-%m-%d")
|
||||
except ValueError:
|
||||
return str(value)
|
||||
|
||||
now = datetime.now()
|
||||
diff = now - dt
|
||||
|
||||
if diff.days > 365:
|
||||
return f"{diff.days // 365} years ago"
|
||||
if diff.days > 30:
|
||||
return f"{diff.days // 30} months ago"
|
||||
if diff.days > 0:
|
||||
return f"{diff.days} days ago"
|
||||
if diff.seconds > 3600:
|
||||
return f"{diff.seconds // 3600} hours ago"
|
||||
return "Just now"
|
||||
except Exception as e:
|
||||
logger.debug(f"Date formatting failed: {e}")
|
||||
return str(value)
|
||||
|
||||
|
||||
def register_blueprints(app):
|
||||
"""Register all application blueprints."""
|
||||
from app.routes import pages_bp, api_bp, streaming_bp
|
||||
|
||||
app.register_blueprint(pages_bp)
|
||||
app.register_blueprint(api_bp)
|
||||
app.register_blueprint(streaming_bp)
|
||||
|
||||
logger.info("Blueprints registered: pages, api, streaming")
|
||||
|
|
|
|||
10
app/routes/__init__.py
Normal file → Executable file
10
app/routes/__init__.py
Normal file → Executable file
|
|
@ -1 +1,9 @@
|
|||
"""KV-Tube Routes Package"""
|
||||
"""
|
||||
KV-Tube Routes Package
|
||||
Exports all Blueprints for registration
|
||||
"""
|
||||
from app.routes.pages import pages_bp
|
||||
from app.routes.api import api_bp
|
||||
from app.routes.streaming import streaming_bp
|
||||
|
||||
__all__ = ['pages_bp', 'api_bp', 'streaming_bp']
|
||||
|
|
|
|||
834
app/routes/api.py
Executable file
834
app/routes/api.py
Executable file
|
|
@ -0,0 +1,834 @@
|
|||
"""
|
||||
KV-Tube API Blueprint
|
||||
All JSON API endpoints for the frontend
|
||||
"""
|
||||
from flask import Blueprint, request, jsonify, Response
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
import json
|
||||
import sqlite3
|
||||
import re
|
||||
import heapq
|
||||
import logging
|
||||
import time
|
||||
import random
|
||||
import concurrent.futures
|
||||
import yt_dlp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
api_bp = Blueprint('api', __name__, url_prefix='/api')
|
||||
|
||||
# Database path
|
||||
DATA_DIR = os.environ.get("KVTUBE_DATA_DIR", "data")
|
||||
DB_NAME = os.path.join(DATA_DIR, "kvtube.db")
|
||||
|
||||
# Caching
|
||||
API_CACHE = {}
|
||||
CACHE_TIMEOUT = 600 # 10 minutes
|
||||
|
||||
|
||||
def get_db_connection():
|
||||
"""Get database connection with row factory."""
|
||||
conn = sqlite3.connect(DB_NAME)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
# --- Helper Functions ---
|
||||
|
||||
def extractive_summary(text, num_sentences=5):
|
||||
"""Extract key sentences from text using word frequency."""
|
||||
# Clean text
|
||||
clean_text = re.sub(r"\[.*?\]", "", text)
|
||||
clean_text = clean_text.replace("\n", " ")
|
||||
|
||||
# Split into sentences
|
||||
sentences = re.split(r"(?<!\w\.\w.)(?<![A-Z][a-z]\.)(?<=\.|\?)\s", clean_text)
|
||||
|
||||
# Calculate word frequencies
|
||||
word_frequencies = {}
|
||||
stop_words = set([
|
||||
"the", "a", "an", "and", "or", "but", "is", "are", "was", "were",
|
||||
"to", "of", "in", "on", "at", "for", "width", "that", "this", "it",
|
||||
"you", "i", "we", "they", "he", "she"
|
||||
])
|
||||
|
||||
for word in re.findall(r"\w+", clean_text.lower()):
|
||||
if word not in stop_words:
|
||||
word_frequencies[word] = word_frequencies.get(word, 0) + 1
|
||||
|
||||
if not word_frequencies:
|
||||
return "Not enough content to summarize."
|
||||
|
||||
# Normalize
|
||||
max_freq = max(word_frequencies.values())
|
||||
for word in word_frequencies:
|
||||
word_frequencies[word] /= max_freq
|
||||
|
||||
# Score sentences
|
||||
sentence_scores = {}
|
||||
for sent in sentences:
|
||||
for word in re.findall(r"\w+", sent.lower()):
|
||||
if word in word_frequencies:
|
||||
sentence_scores[sent] = sentence_scores.get(sent, 0) + word_frequencies[word]
|
||||
|
||||
# Get top sentences
|
||||
summary_sentences = heapq.nlargest(num_sentences, sentence_scores, key=sentence_scores.get)
|
||||
return " ".join(summary_sentences)
|
||||
|
||||
|
||||
def fetch_videos(query, limit=20, filter_type=None, playlist_start=1, playlist_end=None):
|
||||
"""Fetch videos from YouTube search."""
|
||||
try:
|
||||
if not playlist_end:
|
||||
playlist_end = playlist_start + limit
|
||||
|
||||
cmd = [
|
||||
sys.executable, "-m", "yt_dlp",
|
||||
f"ytsearch{limit}:{query}",
|
||||
"--dump-json",
|
||||
"--flat-playlist",
|
||||
"--no-playlist",
|
||||
"--playlist-start", str(playlist_start),
|
||||
"--playlist-end", str(playlist_end),
|
||||
]
|
||||
|
||||
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||
stdout, stderr = proc.communicate()
|
||||
|
||||
results = []
|
||||
for line in stdout.splitlines():
|
||||
try:
|
||||
data = json.loads(line)
|
||||
video_id = data.get("id")
|
||||
if video_id:
|
||||
duration_secs = data.get("duration")
|
||||
|
||||
# Filter logic
|
||||
if filter_type == "video":
|
||||
if duration_secs and int(duration_secs) <= 70:
|
||||
continue
|
||||
if "#shorts" in (data.get("title") or "").lower():
|
||||
continue
|
||||
|
||||
# Format duration
|
||||
duration = None
|
||||
if duration_secs:
|
||||
m, s = divmod(int(duration_secs), 60)
|
||||
h, m = divmod(m, 60)
|
||||
duration = f"{h}:{m:02d}:{s:02d}" if h else f"{m}:{s:02d}"
|
||||
|
||||
results.append({
|
||||
"id": video_id,
|
||||
"title": data.get("title", "Unknown"),
|
||||
"uploader": data.get("uploader") or data.get("channel") or "Unknown",
|
||||
"channel_id": data.get("channel_id"),
|
||||
"uploader_id": data.get("uploader_id"),
|
||||
"thumbnail": f"https://i.ytimg.com/vi/{video_id}/hqdefault.jpg",
|
||||
"view_count": data.get("view_count", 0),
|
||||
"upload_date": data.get("upload_date", ""),
|
||||
"duration": duration,
|
||||
})
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
return results
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching videos: {e}")
|
||||
return []
|
||||
|
||||
|
||||
# --- API Routes ---
|
||||
|
||||
@api_bp.route("/save_video", methods=["POST"])
|
||||
def save_video():
|
||||
"""Deprecated - client-side handled."""
|
||||
return jsonify({"success": True, "message": "Use local storage"})
|
||||
|
||||
|
||||
@api_bp.route("/history")
|
||||
def get_history():
|
||||
"""Get watch history from database."""
|
||||
conn = get_db_connection()
|
||||
rows = conn.execute(
|
||||
'SELECT video_id as id, title, thumbnail FROM user_videos WHERE type = "history" ORDER BY timestamp DESC LIMIT 50'
|
||||
).fetchall()
|
||||
conn.close()
|
||||
return jsonify([dict(row) for row in rows])
|
||||
|
||||
|
||||
@api_bp.route("/suggested")
|
||||
def get_suggested():
|
||||
"""Get suggested videos based on watch history."""
|
||||
client_titles = request.args.get("titles", "")
|
||||
client_channels = request.args.get("channels", "")
|
||||
|
||||
history_titles = []
|
||||
history_channels = []
|
||||
|
||||
if client_titles:
|
||||
history_titles = [t.strip() for t in client_titles.split(",") if t.strip()][:5]
|
||||
if client_channels:
|
||||
history_channels = [c.strip() for c in client_channels.split(",") if c.strip()][:3]
|
||||
|
||||
# Server-side fallback
|
||||
if not history_titles:
|
||||
try:
|
||||
conn = get_db_connection()
|
||||
rows = conn.execute(
|
||||
'SELECT title FROM user_videos WHERE type = "history" ORDER BY timestamp DESC LIMIT 5'
|
||||
).fetchall()
|
||||
conn.close()
|
||||
history_titles = [row['title'] for row in rows]
|
||||
except Exception as e:
|
||||
logger.debug(f"History fetch failed: {e}")
|
||||
|
||||
if not history_titles:
|
||||
return jsonify(fetch_videos("trending", limit=20))
|
||||
|
||||
all_suggestions = []
|
||||
queries = []
|
||||
|
||||
for title in history_titles[:3]:
|
||||
words = title.split()[:4]
|
||||
query_base = " ".join(words)
|
||||
queries.append(f"{query_base} related -shorts")
|
||||
|
||||
for channel in history_channels[:2]:
|
||||
queries.append(f"{channel} latest videos -shorts")
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
|
||||
results = list(executor.map(lambda q: fetch_videos(q, limit=8, filter_type="video"), queries))
|
||||
for res in results:
|
||||
all_suggestions.extend(res)
|
||||
|
||||
unique_vids = {v["id"]: v for v in all_suggestions}.values()
|
||||
final_list = list(unique_vids)
|
||||
random.shuffle(final_list)
|
||||
|
||||
return jsonify(final_list[:30])
|
||||
|
||||
|
||||
@api_bp.route("/related")
|
||||
def get_related_videos():
|
||||
"""Get related videos for a video."""
|
||||
video_id = request.args.get("v")
|
||||
title = request.args.get("title")
|
||||
uploader = request.args.get("uploader", "")
|
||||
page = int(request.args.get("page", 1))
|
||||
limit = int(request.args.get("limit", 10))
|
||||
|
||||
if not title and not video_id:
|
||||
return jsonify({"error": "Video ID or Title required"}), 400
|
||||
|
||||
try:
|
||||
topic_limit = limit // 2
|
||||
channel_limit = limit - topic_limit
|
||||
start = (page - 1) * (limit // 2)
|
||||
|
||||
topic_query = f"{title} related" if title else f"{video_id} related"
|
||||
channel_query = uploader if uploader else topic_query
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
|
||||
future_topic = executor.submit(fetch_videos, topic_query, limit=topic_limit, playlist_start=start + 1)
|
||||
future_channel = executor.submit(fetch_videos, channel_query, limit=channel_limit, playlist_start=start + 1)
|
||||
topic_videos = future_topic.result()
|
||||
channel_videos = future_channel.result()
|
||||
|
||||
combined = channel_videos + topic_videos
|
||||
|
||||
seen = set()
|
||||
if video_id:
|
||||
seen.add(video_id)
|
||||
|
||||
unique_videos = []
|
||||
for v in combined:
|
||||
if v['id'] not in seen:
|
||||
seen.add(v['id'])
|
||||
unique_videos.append(v)
|
||||
|
||||
random.shuffle(unique_videos)
|
||||
return jsonify(unique_videos)
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching related: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@api_bp.route("/download")
|
||||
def get_download_url():
|
||||
"""Get direct MP4 download URL."""
|
||||
video_id = request.args.get("v")
|
||||
if not video_id:
|
||||
return jsonify({"error": "No video ID"}), 400
|
||||
|
||||
try:
|
||||
url = f"https://www.youtube.com/watch?v={video_id}"
|
||||
ydl_opts = {
|
||||
"format": "bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best[protocol!*=m3u8]/best",
|
||||
"noplaylist": True,
|
||||
"quiet": True,
|
||||
"no_warnings": True,
|
||||
"skip_download": True,
|
||||
"youtube_include_dash_manifest": False,
|
||||
"youtube_include_hls_manifest": False,
|
||||
}
|
||||
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||
info = ydl.extract_info(url, download=False)
|
||||
download_url = info.get("url", "")
|
||||
|
||||
if ".m3u8" in download_url or not download_url:
|
||||
formats = info.get("formats", [])
|
||||
for f in reversed(formats):
|
||||
f_url = f.get("url", "")
|
||||
if f_url and "m3u8" not in f_url and f.get("ext") == "mp4":
|
||||
download_url = f_url
|
||||
break
|
||||
|
||||
title = info.get("title", "video")
|
||||
|
||||
if download_url and ".m3u8" not in download_url:
|
||||
return jsonify({"url": download_url, "title": title, "ext": "mp4"})
|
||||
else:
|
||||
return jsonify({
|
||||
"error": "Direct download not available. Try a video downloader site.",
|
||||
"fallback_url": url,
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Download URL error: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@api_bp.route("/download/formats")
|
||||
def get_download_formats():
|
||||
"""Get available download formats for a video."""
|
||||
video_id = request.args.get("v")
|
||||
if not video_id:
|
||||
return jsonify({"success": False, "error": "No video ID"}), 400
|
||||
|
||||
try:
|
||||
url = f"https://www.youtube.com/watch?v={video_id}"
|
||||
ydl_opts = {
|
||||
"format": "best",
|
||||
"noplaylist": True,
|
||||
"quiet": True,
|
||||
"no_warnings": True,
|
||||
"skip_download": True,
|
||||
"youtube_include_dash_manifest": False,
|
||||
"youtube_include_hls_manifest": False,
|
||||
}
|
||||
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||
info = ydl.extract_info(url, download=False)
|
||||
title = info.get("title", "Unknown")
|
||||
duration = info.get("duration", 0)
|
||||
thumbnail = info.get("thumbnail", "")
|
||||
|
||||
video_formats = []
|
||||
audio_formats = []
|
||||
|
||||
for f in info.get("formats", []):
|
||||
f_url = f.get("url", "")
|
||||
if not f_url or "m3u8" in f_url:
|
||||
continue
|
||||
|
||||
f_ext = f.get("ext", "")
|
||||
quality = f.get("format_note", "") or f.get("format", "") or "Unknown"
|
||||
f_filesize = f.get("filesize", 0) or f.get("filesize_approx", 0)
|
||||
|
||||
size_str = ""
|
||||
if f_filesize:
|
||||
if f_filesize > 1024**3:
|
||||
size_str = f"{f_filesize / 1024**3:.1f} GB"
|
||||
elif f_filesize > 1024**2:
|
||||
size_str = f"{f_filesize / 1024**2:.1f} MB"
|
||||
elif f_filesize > 1024:
|
||||
size_str = f"{f_filesize / 1024:.1f} KB"
|
||||
|
||||
if f_ext in ["mp4", "webm"]:
|
||||
vcodec = f.get("vcodec", "none")
|
||||
acodec = f.get("acodec", "none")
|
||||
|
||||
if vcodec != "none" and acodec != "none":
|
||||
video_formats.append({
|
||||
"quality": f"{quality} (with audio)",
|
||||
"ext": f_ext,
|
||||
"size": size_str,
|
||||
"url": f_url,
|
||||
"type": "combined",
|
||||
"has_audio": True,
|
||||
})
|
||||
elif vcodec != "none":
|
||||
video_formats.append({
|
||||
"quality": quality,
|
||||
"ext": f_ext,
|
||||
"size": size_str,
|
||||
"url": f_url,
|
||||
"type": "video",
|
||||
"has_audio": False,
|
||||
})
|
||||
elif acodec != "none":
|
||||
audio_formats.append({
|
||||
"quality": quality,
|
||||
"ext": f_ext,
|
||||
"size": size_str,
|
||||
"url": f_url,
|
||||
"type": "audio",
|
||||
})
|
||||
|
||||
def parse_quality(f):
|
||||
q = f["quality"].lower()
|
||||
for i, res in enumerate(["4k", "2160", "1080", "720", "480", "360", "240", "144"]):
|
||||
if res in q:
|
||||
return i
|
||||
return 99
|
||||
|
||||
video_formats.sort(key=parse_quality)
|
||||
audio_formats.sort(key=parse_quality)
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"video_id": video_id,
|
||||
"title": title,
|
||||
"duration": duration,
|
||||
"thumbnail": thumbnail,
|
||||
"formats": {"video": video_formats[:10], "audio": audio_formats[:5]},
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Download formats error: {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
@api_bp.route("/get_stream_info")
|
||||
def get_stream_info():
|
||||
"""Get video stream info with caching."""
|
||||
video_id = request.args.get("v")
|
||||
if not video_id:
|
||||
return jsonify({"error": "No video ID"}), 400
|
||||
|
||||
try:
|
||||
conn = get_db_connection()
|
||||
cached = conn.execute(
|
||||
"SELECT data, expires_at FROM video_cache WHERE video_id = ?", (video_id,)
|
||||
).fetchone()
|
||||
|
||||
current_time = time.time()
|
||||
if cached:
|
||||
try:
|
||||
expires_at = float(cached["expires_at"])
|
||||
if current_time < expires_at:
|
||||
data = json.loads(cached["data"])
|
||||
conn.close()
|
||||
from urllib.parse import quote
|
||||
proxied_url = f"/video_proxy?url={quote(data['original_url'], safe='')}"
|
||||
data["stream_url"] = proxied_url
|
||||
response = jsonify(data)
|
||||
response.headers["X-Cache"] = "HIT"
|
||||
return response
|
||||
except (ValueError, KeyError):
|
||||
pass
|
||||
|
||||
url = f"https://www.youtube.com/watch?v={video_id}"
|
||||
ydl_opts = {
|
||||
"format": "best[ext=mp4]/best",
|
||||
"noplaylist": True,
|
||||
"quiet": True,
|
||||
"no_warnings": True,
|
||||
"skip_download": True,
|
||||
"force_ipv4": True,
|
||||
"socket_timeout": 10,
|
||||
}
|
||||
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||
try:
|
||||
info = ydl.extract_info(url, download=False)
|
||||
except Exception as e:
|
||||
logger.warning(f"yt-dlp error for {video_id}: {str(e)}")
|
||||
return jsonify({"error": f"Stream extraction failed: {str(e)}"}), 500
|
||||
|
||||
stream_url = info.get("url")
|
||||
if not stream_url:
|
||||
return jsonify({"error": "No stream URL found"}), 500
|
||||
|
||||
# Extract subtitles
|
||||
subtitle_url = None
|
||||
subs = info.get("subtitles") or {}
|
||||
auto_subs = info.get("automatic_captions") or {}
|
||||
|
||||
for lang in ["en", "vi"]:
|
||||
if lang in subs and subs[lang]:
|
||||
subtitle_url = subs[lang][0]["url"]
|
||||
break
|
||||
if lang in auto_subs and auto_subs[lang]:
|
||||
subtitle_url = auto_subs[lang][0]["url"]
|
||||
break
|
||||
|
||||
response_data = {
|
||||
"original_url": stream_url,
|
||||
"title": info.get("title", "Unknown"),
|
||||
"description": info.get("description", ""),
|
||||
"uploader": info.get("uploader", ""),
|
||||
"uploader_id": info.get("uploader_id", ""),
|
||||
"channel_id": info.get("channel_id", ""),
|
||||
"upload_date": info.get("upload_date", ""),
|
||||
"view_count": info.get("view_count", 0),
|
||||
"related": [],
|
||||
"subtitle_url": subtitle_url,
|
||||
}
|
||||
|
||||
# Cache it
|
||||
expiry = current_time + 3600
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO video_cache (video_id, data, expires_at) VALUES (?, ?, ?)",
|
||||
(video_id, json.dumps(response_data), expiry),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
from urllib.parse import quote
|
||||
proxied_url = f"/video_proxy?url={quote(stream_url, safe='')}"
|
||||
response_data["stream_url"] = proxied_url
|
||||
|
||||
response = jsonify(response_data)
|
||||
response.headers["X-Cache"] = "MISS"
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@api_bp.route("/search")
|
||||
def search():
|
||||
"""Search for videos."""
|
||||
query = request.args.get("q")
|
||||
if not query:
|
||||
return jsonify({"error": "No query provided"}), 400
|
||||
|
||||
try:
|
||||
# Check if URL
|
||||
url_match = re.match(r"(?:https?://)?(?:www\.)?(?:youtube\.com/watch\?v=|youtu\.be/)([a-zA-Z0-9_-]{11})", query)
|
||||
if url_match:
|
||||
video_id = url_match.group(1)
|
||||
# Fetch single video info
|
||||
ydl_opts = {"quiet": True, "no_warnings": True, "noplaylist": True}
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||
info = ydl.extract_info(f"https://www.youtube.com/watch?v={video_id}", download=False)
|
||||
return jsonify([{
|
||||
"id": video_id,
|
||||
"title": info.get("title", "Unknown"),
|
||||
"uploader": info.get("uploader", "Unknown"),
|
||||
"thumbnail": f"https://i.ytimg.com/vi/{video_id}/hqdefault.jpg",
|
||||
"view_count": info.get("view_count", 0),
|
||||
"upload_date": info.get("upload_date", ""),
|
||||
"duration": None,
|
||||
}])
|
||||
|
||||
# Standard search
|
||||
results = fetch_videos(query, limit=20, filter_type="video")
|
||||
return jsonify(results)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Search Error: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@api_bp.route("/channel")
|
||||
def get_channel_videos_simple():
|
||||
"""Get videos from a channel."""
|
||||
channel_id = request.args.get("id")
|
||||
if not channel_id:
|
||||
return jsonify({"error": "No channel ID provided"}), 400
|
||||
|
||||
try:
|
||||
# Construct URL
|
||||
if channel_id.startswith("UC"):
|
||||
url = f"https://www.youtube.com/channel/{channel_id}/videos"
|
||||
elif channel_id.startswith("@"):
|
||||
url = f"https://www.youtube.com/{channel_id}/videos"
|
||||
else:
|
||||
url = f"https://www.youtube.com/channel/{channel_id}/videos"
|
||||
|
||||
cmd = [
|
||||
sys.executable, "-m", "yt_dlp",
|
||||
url,
|
||||
"--dump-json",
|
||||
"--flat-playlist",
|
||||
"--playlist-end", "20",
|
||||
"--no-warnings",
|
||||
]
|
||||
|
||||
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||
stdout, stderr = proc.communicate()
|
||||
|
||||
videos = []
|
||||
for line in stdout.splitlines():
|
||||
try:
|
||||
v = json.loads(line)
|
||||
dur_str = None
|
||||
if v.get("duration"):
|
||||
m, s = divmod(int(v["duration"]), 60)
|
||||
h, m = divmod(m, 60)
|
||||
dur_str = f"{h}:{m:02d}:{s:02d}" if h else f"{m}:{s:02d}"
|
||||
|
||||
videos.append({
|
||||
"id": v.get("id"),
|
||||
"title": v.get("title"),
|
||||
"thumbnail": f"https://i.ytimg.com/vi/{v.get('id')}/mqdefault.jpg",
|
||||
"view_count": v.get("view_count") or 0,
|
||||
"duration": dur_str,
|
||||
"upload_date": v.get("upload_date"),
|
||||
"uploader": v.get("uploader") or v.get("channel") or "",
|
||||
})
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
return jsonify(videos)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Channel Fetch Error: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@api_bp.route("/trending")
|
||||
def trending():
|
||||
"""Get trending videos."""
|
||||
from flask import current_app
|
||||
|
||||
category = request.args.get("category", "all")
|
||||
page = int(request.args.get("page", 1))
|
||||
sort = request.args.get("sort", "newest")
|
||||
region = request.args.get("region", "vietnam")
|
||||
|
||||
cache_key = f"trending_{category}_{page}_{sort}_{region}"
|
||||
|
||||
# Check cache
|
||||
if cache_key in API_CACHE:
|
||||
cached_time, cached_data = API_CACHE[cache_key]
|
||||
if time.time() - cached_time < CACHE_TIMEOUT:
|
||||
return jsonify(cached_data)
|
||||
|
||||
try:
|
||||
# Category search queries
|
||||
queries = {
|
||||
"all": "trending videos 2024",
|
||||
"music": "music trending",
|
||||
"gaming": "gaming trending",
|
||||
"news": "news today",
|
||||
"tech": "technology reviews 2024",
|
||||
"movies": "movie trailers 2024",
|
||||
"sports": "sports highlights",
|
||||
}
|
||||
|
||||
# For 'all' category, always fetch from multiple categories for diverse content
|
||||
if category == "all":
|
||||
region_suffix = " vietnam" if region == "vietnam" else ""
|
||||
|
||||
# Rotate through different queries based on page for variety
|
||||
query_sets = [
|
||||
[f"trending videos 2024{region_suffix}", f"music trending{region_suffix}", f"tech reviews 2024{region_suffix}"],
|
||||
[f"movie trailers 2024{region_suffix}", f"gaming trending{region_suffix}", f"sports highlights{region_suffix}"],
|
||||
[f"trending music 2024{region_suffix}", f"viral videos{region_suffix}", f"entertainment news{region_suffix}"],
|
||||
[f"tech gadgets{region_suffix}", f"comedy videos{region_suffix}", f"documentary{region_suffix}"],
|
||||
]
|
||||
|
||||
# Use different query set based on page to get variety
|
||||
query_index = (page - 1) % len(query_sets)
|
||||
current_queries = query_sets[query_index]
|
||||
|
||||
# Calculate offset within query set
|
||||
start_offset = ((page - 1) // len(query_sets)) * 7 + 1
|
||||
|
||||
# Fetch from multiple categories in parallel
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=3) as executor:
|
||||
futures = [
|
||||
executor.submit(fetch_videos, q, limit=7, filter_type="video", playlist_start=start_offset)
|
||||
for q in current_queries
|
||||
]
|
||||
results = [f.result() for f in futures]
|
||||
|
||||
# Combine all videos and deduplicate
|
||||
all_videos = []
|
||||
seen_ids = set()
|
||||
|
||||
for video_list in results:
|
||||
for vid in video_list:
|
||||
if vid['id'] not in seen_ids:
|
||||
seen_ids.add(vid['id'])
|
||||
all_videos.append(vid)
|
||||
|
||||
# Shuffle for variety
|
||||
random.shuffle(all_videos)
|
||||
|
||||
# Cache result
|
||||
API_CACHE[cache_key] = (time.time(), all_videos)
|
||||
return jsonify(all_videos)
|
||||
|
||||
# Single category - support proper pagination
|
||||
query = queries.get(category, queries["all"])
|
||||
if region == "vietnam":
|
||||
query += " vietnam"
|
||||
|
||||
videos = fetch_videos(query, limit=20, filter_type="video", playlist_start=(page-1)*20+1)
|
||||
|
||||
# Cache result
|
||||
API_CACHE[cache_key] = (time.time(), videos)
|
||||
|
||||
return jsonify(videos)
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@api_bp.route("/summarize")
|
||||
def summarize_video():
|
||||
"""Get video summary from transcript."""
|
||||
video_id = request.args.get("v")
|
||||
if not video_id:
|
||||
return jsonify({"error": "No video ID"}), 400
|
||||
|
||||
try:
|
||||
from youtube_transcript_api import YouTubeTranscriptApi
|
||||
from youtube_transcript_api._errors import TranscriptsDisabled
|
||||
|
||||
transcript_list = YouTubeTranscriptApi.list_transcripts(video_id)
|
||||
|
||||
try:
|
||||
transcript = transcript_list.find_transcript(["en", "vi"])
|
||||
except Exception:
|
||||
transcript = transcript_list.find_generated_transcript(["en", "vi"])
|
||||
|
||||
transcript_data = transcript.fetch()
|
||||
full_text = " ".join([entry["text"] for entry in transcript_data])
|
||||
summary = extractive_summary(full_text, num_sentences=7)
|
||||
|
||||
return jsonify({"success": True, "summary": summary})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "message": f"Could not summarize: {str(e)}"})
|
||||
|
||||
|
||||
@api_bp.route("/transcript")
|
||||
def get_transcript():
|
||||
"""Get video transcript."""
|
||||
video_id = request.args.get("v")
|
||||
if not video_id:
|
||||
return jsonify({"success": False, "error": "No video ID provided"}), 400
|
||||
|
||||
try:
|
||||
from youtube_transcript_api import YouTubeTranscriptApi
|
||||
|
||||
transcript_list = YouTubeTranscriptApi.list_transcripts(video_id)
|
||||
|
||||
try:
|
||||
transcript = transcript_list.find_transcript(["en", "vi"])
|
||||
except Exception:
|
||||
transcript = transcript_list.find_generated_transcript(["en", "vi"])
|
||||
|
||||
transcript_data = transcript.fetch()
|
||||
full_text = " ".join([entry["text"] for entry in transcript_data])
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"video_id": video_id,
|
||||
"transcript": transcript_data,
|
||||
"language": "en",
|
||||
"is_generated": True,
|
||||
"full_text": full_text[:10000],
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "error": f"Could not load transcript: {str(e)}"})
|
||||
|
||||
|
||||
@api_bp.route("/update_ytdlp", methods=["POST"])
|
||||
def update_ytdlp():
|
||||
"""Update yt-dlp to latest version."""
|
||||
try:
|
||||
cmd = [sys.executable, "-m", "pip", "install", "-U", "yt-dlp"]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
|
||||
if result.returncode == 0:
|
||||
ver_cmd = [sys.executable, "-m", "yt_dlp", "--version"]
|
||||
ver_result = subprocess.run(ver_cmd, capture_output=True, text=True)
|
||||
version = ver_result.stdout.strip()
|
||||
return jsonify({"success": True, "message": f"Updated to {version}"})
|
||||
else:
|
||||
return jsonify({"success": False, "message": f"Update failed: {result.stderr}"}), 500
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "message": str(e)}), 500
|
||||
|
||||
|
||||
@api_bp.route("/comments")
|
||||
def get_comments():
|
||||
"""Get comments for a video."""
|
||||
video_id = request.args.get("v")
|
||||
if not video_id:
|
||||
return jsonify({"error": "No video ID"}), 400
|
||||
|
||||
try:
|
||||
url = f"https://www.youtube.com/watch?v={video_id}"
|
||||
cmd = [
|
||||
sys.executable, "-m", "yt_dlp",
|
||||
url,
|
||||
"--write-comments",
|
||||
"--skip-download",
|
||||
"--dump-json",
|
||||
]
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
||||
|
||||
if result.returncode == 0:
|
||||
data = json.loads(result.stdout)
|
||||
comments_data = data.get("comments", [])
|
||||
|
||||
comments = []
|
||||
for c in comments_data[:50]:
|
||||
comments.append({
|
||||
"author": c.get("author", "Unknown"),
|
||||
"author_thumbnail": c.get("author_thumbnail", ""),
|
||||
"text": c.get("text", ""),
|
||||
"likes": c.get("like_count", 0),
|
||||
"time": c.get("time_text", ""),
|
||||
"is_pinned": c.get("is_pinned", False),
|
||||
})
|
||||
|
||||
return jsonify({"comments": comments, "count": data.get("comment_count", len(comments))})
|
||||
else:
|
||||
return jsonify({"comments": [], "count": 0, "error": "Could not load comments"})
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return jsonify({"comments": [], "count": 0, "error": "Comments loading timed out"})
|
||||
except Exception as e:
|
||||
return jsonify({"comments": [], "count": 0, "error": str(e)})
|
||||
|
||||
|
||||
@api_bp.route("/captions.vtt")
|
||||
def get_captions_vtt():
|
||||
"""Get captions in WebVTT format."""
|
||||
video_id = request.args.get("v")
|
||||
if not video_id:
|
||||
return "WEBVTT\n\n", 400, {'Content-Type': 'text/vtt'}
|
||||
|
||||
try:
|
||||
from youtube_transcript_api import YouTubeTranscriptApi
|
||||
from youtube_transcript_api.formatters import WebVTTFormatter
|
||||
|
||||
transcript_list = YouTubeTranscriptApi.list_transcripts(video_id)
|
||||
|
||||
try:
|
||||
transcript = transcript_list.find_transcript(["en", "vi"])
|
||||
except Exception:
|
||||
transcript = transcript_list.find_generated_transcript(["en", "vi"])
|
||||
|
||||
transcript_data = transcript.fetch()
|
||||
formatter = WebVTTFormatter()
|
||||
vtt_formatted = formatter.format_transcript(transcript_data)
|
||||
|
||||
return Response(vtt_formatted, mimetype='text/vtt')
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Caption Error: {e}")
|
||||
return "WEBVTT\n\n", 200, {'Content-Type': 'text/vtt'}
|
||||
172
app/routes/pages.py
Executable file
172
app/routes/pages.py
Executable file
|
|
@ -0,0 +1,172 @@
|
|||
"""
|
||||
KV-Tube Pages Blueprint
|
||||
HTML page routes for the web interface
|
||||
"""
|
||||
from flask import Blueprint, render_template, request, url_for
|
||||
|
||||
pages_bp = Blueprint('pages', __name__)
|
||||
|
||||
|
||||
@pages_bp.route("/")
|
||||
def index():
|
||||
"""Home page with trending videos."""
|
||||
return render_template("index.html", page="home")
|
||||
|
||||
|
||||
@pages_bp.route("/results")
|
||||
def results():
|
||||
"""Search results page."""
|
||||
query = request.args.get("search_query", "")
|
||||
return render_template("index.html", page="results", query=query)
|
||||
|
||||
|
||||
@pages_bp.route("/my-videos")
|
||||
def my_videos():
|
||||
"""User's saved videos page (client-side rendered)."""
|
||||
return render_template("my_videos.html")
|
||||
|
||||
|
||||
@pages_bp.route("/settings")
|
||||
def settings():
|
||||
"""Settings page."""
|
||||
return render_template("settings.html", page="settings")
|
||||
|
||||
|
||||
@pages_bp.route("/downloads")
|
||||
def downloads():
|
||||
"""Downloads page."""
|
||||
return render_template("downloads.html", page="downloads")
|
||||
|
||||
|
||||
@pages_bp.route("/watch")
|
||||
def watch():
|
||||
"""Video watch page."""
|
||||
from flask import url_for as flask_url_for
|
||||
|
||||
video_id = request.args.get("v")
|
||||
local_file = request.args.get("local")
|
||||
|
||||
if local_file:
|
||||
return render_template(
|
||||
"watch.html",
|
||||
video_type="local",
|
||||
src=flask_url_for("streaming.stream_local", filename=local_file),
|
||||
title=local_file,
|
||||
)
|
||||
|
||||
if not video_id:
|
||||
return "No video ID provided", 400
|
||||
return render_template("watch.html", video_type="youtube", video_id=video_id)
|
||||
|
||||
|
||||
@pages_bp.route("/channel/<channel_id>")
|
||||
def channel(channel_id):
|
||||
"""Channel page with videos list."""
|
||||
import sys
|
||||
import subprocess
|
||||
import json
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if not channel_id:
|
||||
from flask import redirect, url_for as flask_url_for
|
||||
return redirect(flask_url_for("pages.index"))
|
||||
|
||||
try:
|
||||
# Robustness: Resolve name to ID if needed
|
||||
real_id_or_url = channel_id
|
||||
is_search_fallback = False
|
||||
|
||||
# If channel_id is @UCN... format, strip the @ to get the proper UC ID
|
||||
if channel_id.startswith("@UC"):
|
||||
real_id_or_url = channel_id[1:]
|
||||
|
||||
if not real_id_or_url.startswith("UC") and not real_id_or_url.startswith("@"):
|
||||
search_cmd = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"yt_dlp",
|
||||
f"ytsearch1:{channel_id}",
|
||||
"--dump-json",
|
||||
"--default-search",
|
||||
"ytsearch",
|
||||
"--no-playlist",
|
||||
]
|
||||
try:
|
||||
proc_search = subprocess.run(search_cmd, capture_output=True, text=True)
|
||||
if proc_search.returncode == 0:
|
||||
first_result = json.loads(proc_search.stdout.splitlines()[0])
|
||||
if first_result.get("channel_id"):
|
||||
real_id_or_url = first_result.get("channel_id")
|
||||
is_search_fallback = True
|
||||
except Exception as e:
|
||||
logger.debug(f"Channel search fallback failed: {e}")
|
||||
|
||||
# Fetch basic channel info
|
||||
channel_info = {
|
||||
"id": real_id_or_url,
|
||||
"title": channel_id if not is_search_fallback else "Loading...",
|
||||
"avatar": None,
|
||||
"banner": None,
|
||||
"subscribers": None,
|
||||
}
|
||||
|
||||
# Determine target URL for metadata fetch
|
||||
target_url = real_id_or_url
|
||||
if target_url.startswith("UC"):
|
||||
target_url = f"https://www.youtube.com/channel/{target_url}"
|
||||
elif target_url.startswith("@"):
|
||||
target_url = f"https://www.youtube.com/{target_url}"
|
||||
|
||||
cmd = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"yt_dlp",
|
||||
target_url,
|
||||
"--dump-json",
|
||||
"--flat-playlist",
|
||||
"--playlist-end",
|
||||
"1",
|
||||
"--no-warnings",
|
||||
]
|
||||
|
||||
proc = subprocess.Popen(
|
||||
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
|
||||
)
|
||||
stdout, stderr = proc.communicate()
|
||||
|
||||
if stdout:
|
||||
try:
|
||||
first = json.loads(stdout.splitlines()[0])
|
||||
channel_info["title"] = (
|
||||
first.get("channel")
|
||||
or first.get("uploader")
|
||||
or channel_info["title"]
|
||||
)
|
||||
channel_info["id"] = first.get("channel_id") or channel_info["id"]
|
||||
except json.JSONDecodeError as e:
|
||||
logger.debug(f"Channel JSON parse failed: {e}")
|
||||
|
||||
# If title is still just the ID, try to get channel name
|
||||
if channel_info["title"].startswith("UC") or channel_info["title"].startswith("@"):
|
||||
try:
|
||||
name_cmd = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"yt_dlp",
|
||||
target_url,
|
||||
"--print", "channel",
|
||||
"--playlist-items", "1",
|
||||
"--no-warnings",
|
||||
]
|
||||
name_proc = subprocess.run(name_cmd, capture_output=True, text=True, timeout=15)
|
||||
if name_proc.returncode == 0 and name_proc.stdout.strip():
|
||||
channel_info["title"] = name_proc.stdout.strip()
|
||||
except Exception as e:
|
||||
logger.debug(f"Channel name fetch failed: {e}")
|
||||
|
||||
return render_template("channel.html", channel=channel_info)
|
||||
|
||||
except Exception as e:
|
||||
return f"Error loading channel: {str(e)}", 500
|
||||
97
app/routes/streaming.py
Executable file
97
app/routes/streaming.py
Executable file
|
|
@ -0,0 +1,97 @@
|
|||
"""
|
||||
KV-Tube Streaming Blueprint
|
||||
Video streaming and proxy routes
|
||||
"""
|
||||
from flask import Blueprint, request, Response, stream_with_context, send_from_directory
|
||||
import requests
|
||||
import os
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
streaming_bp = Blueprint('streaming', __name__)
|
||||
|
||||
# Configuration for local video path
|
||||
VIDEO_DIR = os.environ.get("KVTUBE_VIDEO_DIR", "./videos")
|
||||
|
||||
|
||||
@streaming_bp.route("/stream/<path:filename>")
|
||||
def stream_local(filename):
|
||||
"""Stream local video files."""
|
||||
return send_from_directory(VIDEO_DIR, filename)
|
||||
|
||||
|
||||
@streaming_bp.route("/video_proxy")
|
||||
def video_proxy():
|
||||
"""Proxy video streams with HLS manifest rewriting."""
|
||||
url = request.args.get("url")
|
||||
if not url:
|
||||
return "No URL provided", 400
|
||||
|
||||
# Forward headers to mimic browser and support seeking
|
||||
headers = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
|
||||
}
|
||||
|
||||
# Support Range requests (scrubbing)
|
||||
range_header = request.headers.get("Range")
|
||||
if range_header:
|
||||
headers["Range"] = range_header
|
||||
|
||||
try:
|
||||
req = requests.get(url, headers=headers, stream=True, timeout=30)
|
||||
|
||||
# Handle HLS (M3U8) Rewriting - CRITICAL for 1080p+ and proper sync
|
||||
content_type = req.headers.get("content-type", "").lower()
|
||||
url_path = url.split("?")[0]
|
||||
is_manifest = (
|
||||
url_path.endswith(".m3u8")
|
||||
or "application/x-mpegurl" in content_type
|
||||
or "application/vnd.apple.mpegurl" in content_type
|
||||
)
|
||||
|
||||
if is_manifest:
|
||||
content = req.text
|
||||
base_url = url.rsplit("/", 1)[0]
|
||||
new_lines = []
|
||||
|
||||
for line in content.splitlines():
|
||||
if line.strip() and not line.startswith("#"):
|
||||
# If relative, make absolute
|
||||
if not line.startswith("http"):
|
||||
full_url = f"{base_url}/{line}"
|
||||
else:
|
||||
full_url = line
|
||||
|
||||
from urllib.parse import quote
|
||||
quoted_url = quote(full_url, safe="")
|
||||
new_lines.append(f"/video_proxy?url={quoted_url}")
|
||||
else:
|
||||
new_lines.append(line)
|
||||
|
||||
return Response(
|
||||
"\n".join(new_lines), content_type="application/vnd.apple.mpegurl"
|
||||
)
|
||||
|
||||
# Standard Stream Proxy (Binary)
|
||||
excluded_headers = [
|
||||
"content-encoding",
|
||||
"content-length",
|
||||
"transfer-encoding",
|
||||
"connection",
|
||||
]
|
||||
response_headers = [
|
||||
(name, value)
|
||||
for (name, value) in req.headers.items()
|
||||
if name.lower() not in excluded_headers
|
||||
]
|
||||
|
||||
return Response(
|
||||
stream_with_context(req.iter_content(chunk_size=8192)),
|
||||
status=req.status_code,
|
||||
headers=response_headers,
|
||||
content_type=req.headers.get("content-type"),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Proxy Error: {e}")
|
||||
return str(e), 500
|
||||
2
app/services/__init__.py
Normal file → Executable file
2
app/services/__init__.py
Normal file → Executable file
|
|
@ -1 +1 @@
|
|||
"""KV-Tube Services Package"""
|
||||
"""KV-Tube Services Package"""
|
||||
|
|
|
|||
434
app/services/cache.py
Normal file → Executable file
434
app/services/cache.py
Normal file → Executable file
|
|
@ -1,217 +1,217 @@
|
|||
"""
|
||||
Cache Service Module
|
||||
SQLite-based caching with connection pooling
|
||||
"""
|
||||
import sqlite3
|
||||
import json
|
||||
import time
|
||||
import threading
|
||||
import logging
|
||||
from typing import Optional, Any, Dict
|
||||
from contextlib import contextmanager
|
||||
from config import Config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ConnectionPool:
|
||||
"""Thread-safe SQLite connection pool"""
|
||||
|
||||
def __init__(self, db_path: str, max_connections: int = 5):
|
||||
self.db_path = db_path
|
||||
self.max_connections = max_connections
|
||||
self._local = threading.local()
|
||||
self._lock = threading.Lock()
|
||||
self._init_db()
|
||||
|
||||
def _init_db(self):
|
||||
"""Initialize database tables"""
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
c = conn.cursor()
|
||||
|
||||
# Users table
|
||||
c.execute('''CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password TEXT NOT NULL
|
||||
)''')
|
||||
|
||||
# User videos (history/saved)
|
||||
c.execute('''CREATE TABLE IF NOT EXISTS user_videos (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER,
|
||||
video_id TEXT,
|
||||
title TEXT,
|
||||
thumbnail TEXT,
|
||||
type TEXT,
|
||||
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id)
|
||||
)''')
|
||||
|
||||
# Video cache
|
||||
c.execute('''CREATE TABLE IF NOT EXISTS video_cache (
|
||||
video_id TEXT PRIMARY KEY,
|
||||
data TEXT,
|
||||
expires_at REAL
|
||||
)''')
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def get_connection(self) -> sqlite3.Connection:
|
||||
"""Get a thread-local database connection"""
|
||||
if not hasattr(self._local, 'connection') or self._local.connection is None:
|
||||
self._local.connection = sqlite3.connect(self.db_path)
|
||||
self._local.connection.row_factory = sqlite3.Row
|
||||
return self._local.connection
|
||||
|
||||
@contextmanager
|
||||
def connection(self):
|
||||
"""Context manager for database connections"""
|
||||
conn = self.get_connection()
|
||||
try:
|
||||
yield conn
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logger.error(f"Database error: {e}")
|
||||
raise
|
||||
|
||||
def close(self):
|
||||
"""Close the thread-local connection"""
|
||||
if hasattr(self._local, 'connection') and self._local.connection:
|
||||
self._local.connection.close()
|
||||
self._local.connection = None
|
||||
|
||||
|
||||
# Global connection pool
|
||||
_pool: Optional[ConnectionPool] = None
|
||||
|
||||
|
||||
def get_pool() -> ConnectionPool:
|
||||
"""Get or create the global connection pool"""
|
||||
global _pool
|
||||
if _pool is None:
|
||||
_pool = ConnectionPool(Config.DB_NAME)
|
||||
return _pool
|
||||
|
||||
|
||||
def get_db_connection() -> sqlite3.Connection:
|
||||
"""Get a database connection - backward compatibility"""
|
||||
return get_pool().get_connection()
|
||||
|
||||
|
||||
class CacheService:
|
||||
"""Service for caching video metadata"""
|
||||
|
||||
@staticmethod
|
||||
def get_video_cache(video_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get cached video data if not expired
|
||||
|
||||
Args:
|
||||
video_id: YouTube video ID
|
||||
|
||||
Returns:
|
||||
Cached data dict or None if not found/expired
|
||||
"""
|
||||
try:
|
||||
pool = get_pool()
|
||||
with pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
'SELECT data, expires_at FROM video_cache WHERE video_id = ?',
|
||||
(video_id,)
|
||||
).fetchone()
|
||||
|
||||
if row:
|
||||
expires_at = float(row['expires_at'])
|
||||
if time.time() < expires_at:
|
||||
return json.loads(row['data'])
|
||||
else:
|
||||
# Expired, clean it up
|
||||
conn.execute('DELETE FROM video_cache WHERE video_id = ?', (video_id,))
|
||||
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Cache get error for {video_id}: {e}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def set_video_cache(video_id: str, data: Dict[str, Any], ttl: int = None) -> bool:
|
||||
"""
|
||||
Cache video data
|
||||
|
||||
Args:
|
||||
video_id: YouTube video ID
|
||||
data: Data to cache
|
||||
ttl: Time to live in seconds (default from config)
|
||||
|
||||
Returns:
|
||||
True if cached successfully
|
||||
"""
|
||||
try:
|
||||
if ttl is None:
|
||||
ttl = Config.CACHE_VIDEO_TTL
|
||||
|
||||
expires_at = time.time() + ttl
|
||||
|
||||
pool = get_pool()
|
||||
with pool.connection() as conn:
|
||||
conn.execute(
|
||||
'INSERT OR REPLACE INTO video_cache (video_id, data, expires_at) VALUES (?, ?, ?)',
|
||||
(video_id, json.dumps(data), expires_at)
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Cache set error for {video_id}: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def clear_expired():
|
||||
"""Remove all expired cache entries"""
|
||||
try:
|
||||
pool = get_pool()
|
||||
with pool.connection() as conn:
|
||||
conn.execute('DELETE FROM video_cache WHERE expires_at < ?', (time.time(),))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Cache cleanup error: {e}")
|
||||
|
||||
|
||||
class HistoryService:
|
||||
"""Service for user video history"""
|
||||
|
||||
@staticmethod
|
||||
def get_history(limit: int = 50) -> list:
|
||||
"""Get watch history"""
|
||||
try:
|
||||
pool = get_pool()
|
||||
with pool.connection() as conn:
|
||||
rows = conn.execute(
|
||||
'SELECT video_id as id, title, thumbnail FROM user_videos WHERE type = "history" ORDER BY timestamp DESC LIMIT ?',
|
||||
(limit,)
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"History get error: {e}")
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def add_to_history(video_id: str, title: str, thumbnail: str) -> bool:
|
||||
"""Add a video to history"""
|
||||
try:
|
||||
pool = get_pool()
|
||||
with pool.connection() as conn:
|
||||
conn.execute(
|
||||
'INSERT INTO user_videos (user_id, video_id, title, thumbnail, type) VALUES (?, ?, ?, ?, ?)',
|
||||
(1, video_id, title, thumbnail, 'history')
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"History add error: {e}")
|
||||
return False
|
||||
"""
|
||||
Cache Service Module
|
||||
SQLite-based caching with connection pooling
|
||||
"""
|
||||
import sqlite3
|
||||
import json
|
||||
import time
|
||||
import threading
|
||||
import logging
|
||||
from typing import Optional, Any, Dict
|
||||
from contextlib import contextmanager
|
||||
from config import Config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ConnectionPool:
|
||||
"""Thread-safe SQLite connection pool"""
|
||||
|
||||
def __init__(self, db_path: str, max_connections: int = 5):
|
||||
self.db_path = db_path
|
||||
self.max_connections = max_connections
|
||||
self._local = threading.local()
|
||||
self._lock = threading.Lock()
|
||||
self._init_db()
|
||||
|
||||
def _init_db(self):
|
||||
"""Initialize database tables"""
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
c = conn.cursor()
|
||||
|
||||
# Users table
|
||||
c.execute('''CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password TEXT NOT NULL
|
||||
)''')
|
||||
|
||||
# User videos (history/saved)
|
||||
c.execute('''CREATE TABLE IF NOT EXISTS user_videos (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER,
|
||||
video_id TEXT,
|
||||
title TEXT,
|
||||
thumbnail TEXT,
|
||||
type TEXT,
|
||||
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id)
|
||||
)''')
|
||||
|
||||
# Video cache
|
||||
c.execute('''CREATE TABLE IF NOT EXISTS video_cache (
|
||||
video_id TEXT PRIMARY KEY,
|
||||
data TEXT,
|
||||
expires_at REAL
|
||||
)''')
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def get_connection(self) -> sqlite3.Connection:
|
||||
"""Get a thread-local database connection"""
|
||||
if not hasattr(self._local, 'connection') or self._local.connection is None:
|
||||
self._local.connection = sqlite3.connect(self.db_path)
|
||||
self._local.connection.row_factory = sqlite3.Row
|
||||
return self._local.connection
|
||||
|
||||
@contextmanager
|
||||
def connection(self):
|
||||
"""Context manager for database connections"""
|
||||
conn = self.get_connection()
|
||||
try:
|
||||
yield conn
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logger.error(f"Database error: {e}")
|
||||
raise
|
||||
|
||||
def close(self):
|
||||
"""Close the thread-local connection"""
|
||||
if hasattr(self._local, 'connection') and self._local.connection:
|
||||
self._local.connection.close()
|
||||
self._local.connection = None
|
||||
|
||||
|
||||
# Global connection pool
|
||||
_pool: Optional[ConnectionPool] = None
|
||||
|
||||
|
||||
def get_pool() -> ConnectionPool:
|
||||
"""Get or create the global connection pool"""
|
||||
global _pool
|
||||
if _pool is None:
|
||||
_pool = ConnectionPool(Config.DB_NAME)
|
||||
return _pool
|
||||
|
||||
|
||||
def get_db_connection() -> sqlite3.Connection:
|
||||
"""Get a database connection - backward compatibility"""
|
||||
return get_pool().get_connection()
|
||||
|
||||
|
||||
class CacheService:
|
||||
"""Service for caching video metadata"""
|
||||
|
||||
@staticmethod
|
||||
def get_video_cache(video_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get cached video data if not expired
|
||||
|
||||
Args:
|
||||
video_id: YouTube video ID
|
||||
|
||||
Returns:
|
||||
Cached data dict or None if not found/expired
|
||||
"""
|
||||
try:
|
||||
pool = get_pool()
|
||||
with pool.connection() as conn:
|
||||
row = conn.execute(
|
||||
'SELECT data, expires_at FROM video_cache WHERE video_id = ?',
|
||||
(video_id,)
|
||||
).fetchone()
|
||||
|
||||
if row:
|
||||
expires_at = float(row['expires_at'])
|
||||
if time.time() < expires_at:
|
||||
return json.loads(row['data'])
|
||||
else:
|
||||
# Expired, clean it up
|
||||
conn.execute('DELETE FROM video_cache WHERE video_id = ?', (video_id,))
|
||||
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Cache get error for {video_id}: {e}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def set_video_cache(video_id: str, data: Dict[str, Any], ttl: int = None) -> bool:
|
||||
"""
|
||||
Cache video data
|
||||
|
||||
Args:
|
||||
video_id: YouTube video ID
|
||||
data: Data to cache
|
||||
ttl: Time to live in seconds (default from config)
|
||||
|
||||
Returns:
|
||||
True if cached successfully
|
||||
"""
|
||||
try:
|
||||
if ttl is None:
|
||||
ttl = Config.CACHE_VIDEO_TTL
|
||||
|
||||
expires_at = time.time() + ttl
|
||||
|
||||
pool = get_pool()
|
||||
with pool.connection() as conn:
|
||||
conn.execute(
|
||||
'INSERT OR REPLACE INTO video_cache (video_id, data, expires_at) VALUES (?, ?, ?)',
|
||||
(video_id, json.dumps(data), expires_at)
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Cache set error for {video_id}: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def clear_expired():
|
||||
"""Remove all expired cache entries"""
|
||||
try:
|
||||
pool = get_pool()
|
||||
with pool.connection() as conn:
|
||||
conn.execute('DELETE FROM video_cache WHERE expires_at < ?', (time.time(),))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Cache cleanup error: {e}")
|
||||
|
||||
|
||||
class HistoryService:
|
||||
"""Service for user video history"""
|
||||
|
||||
@staticmethod
|
||||
def get_history(limit: int = 50) -> list:
|
||||
"""Get watch history"""
|
||||
try:
|
||||
pool = get_pool()
|
||||
with pool.connection() as conn:
|
||||
rows = conn.execute(
|
||||
'SELECT video_id as id, title, thumbnail FROM user_videos WHERE type = "history" ORDER BY timestamp DESC LIMIT ?',
|
||||
(limit,)
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"History get error: {e}")
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def add_to_history(video_id: str, title: str, thumbnail: str) -> bool:
|
||||
"""Add a video to history"""
|
||||
try:
|
||||
pool = get_pool()
|
||||
with pool.connection() as conn:
|
||||
conn.execute(
|
||||
'INSERT INTO user_videos (user_id, video_id, title, thumbnail, type) VALUES (?, ?, ?, ?, ?)',
|
||||
(1, video_id, title, thumbnail, 'history')
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"History add error: {e}")
|
||||
return False
|
||||
|
|
|
|||
232
app/services/summarizer.py
Normal file → Executable file
232
app/services/summarizer.py
Normal file → Executable file
|
|
@ -1,116 +1,116 @@
|
|||
"""
|
||||
Summarizer Service Module
|
||||
Extractive text summarization for video transcripts
|
||||
"""
|
||||
import re
|
||||
import heapq
|
||||
import logging
|
||||
from typing import List
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Stop words for summarization
|
||||
STOP_WORDS = frozenset([
|
||||
'the', 'a', 'an', 'and', 'or', 'but', 'is', 'are', 'was', 'were',
|
||||
'to', 'of', 'in', 'on', 'at', 'for', 'with', 'that', 'this', 'it',
|
||||
'you', 'i', 'we', 'they', 'he', 'she', 'be', 'have', 'has', 'do',
|
||||
'does', 'did', 'will', 'would', 'could', 'should', 'may', 'might',
|
||||
'must', 'can', 'not', 'no', 'so', 'as', 'if', 'then', 'than',
|
||||
'when', 'where', 'what', 'which', 'who', 'how', 'why', 'all',
|
||||
'each', 'every', 'both', 'few', 'more', 'most', 'other', 'some',
|
||||
'such', 'any', 'only', 'own', 'same', 'just', 'now', 'also', 'very'
|
||||
])
|
||||
|
||||
|
||||
def extractive_summary(text: str, num_sentences: int = 5) -> str:
|
||||
"""
|
||||
Generate an extractive summary of text
|
||||
|
||||
Args:
|
||||
text: Input text to summarize
|
||||
num_sentences: Number of sentences to extract
|
||||
|
||||
Returns:
|
||||
Summary string with top-ranked sentences
|
||||
"""
|
||||
if not text or not text.strip():
|
||||
return "Not enough content to summarize."
|
||||
|
||||
# Clean text - remove metadata like [Music] common in auto-captions
|
||||
clean_text = re.sub(r'\[.*?\]', '', text)
|
||||
clean_text = clean_text.replace('\n', ' ')
|
||||
clean_text = re.sub(r'\s+', ' ', clean_text).strip()
|
||||
|
||||
if len(clean_text) < 100:
|
||||
return clean_text
|
||||
|
||||
# Split into sentences
|
||||
sentences = _split_sentences(clean_text)
|
||||
|
||||
if len(sentences) <= num_sentences:
|
||||
return clean_text
|
||||
|
||||
# Calculate word frequencies
|
||||
word_frequencies = _calculate_word_frequencies(clean_text)
|
||||
|
||||
if not word_frequencies:
|
||||
return "Not enough content to summarize."
|
||||
|
||||
# Score sentences
|
||||
sentence_scores = _score_sentences(sentences, word_frequencies)
|
||||
|
||||
# Extract top N sentences
|
||||
top_sentences = heapq.nlargest(num_sentences, sentence_scores, key=sentence_scores.get)
|
||||
|
||||
# Return in original order
|
||||
ordered = [s for s in sentences if s in top_sentences]
|
||||
|
||||
return ' '.join(ordered)
|
||||
|
||||
|
||||
def _split_sentences(text: str) -> List[str]:
|
||||
"""Split text into sentences"""
|
||||
# Regex for sentence splitting - handles abbreviations
|
||||
pattern = r'(?<!\w\.\w.)(?<![A-Z][a-z]\.)(?<=\.|\?|\!)\s'
|
||||
sentences = re.split(pattern, text)
|
||||
|
||||
# Filter out very short sentences
|
||||
return [s.strip() for s in sentences if len(s.strip()) > 20]
|
||||
|
||||
|
||||
def _calculate_word_frequencies(text: str) -> dict:
|
||||
"""Calculate normalized word frequencies"""
|
||||
word_frequencies = {}
|
||||
|
||||
words = re.findall(r'\w+', text.lower())
|
||||
|
||||
for word in words:
|
||||
if word not in STOP_WORDS and len(word) > 2:
|
||||
word_frequencies[word] = word_frequencies.get(word, 0) + 1
|
||||
|
||||
if not word_frequencies:
|
||||
return {}
|
||||
|
||||
# Normalize by max frequency
|
||||
max_freq = max(word_frequencies.values())
|
||||
for word in word_frequencies:
|
||||
word_frequencies[word] = word_frequencies[word] / max_freq
|
||||
|
||||
return word_frequencies
|
||||
|
||||
|
||||
def _score_sentences(sentences: List[str], word_frequencies: dict) -> dict:
|
||||
"""Score sentences based on word frequencies"""
|
||||
sentence_scores = {}
|
||||
|
||||
for sentence in sentences:
|
||||
words = re.findall(r'\w+', sentence.lower())
|
||||
score = sum(word_frequencies.get(word, 0) for word in words)
|
||||
|
||||
# Normalize by sentence length to avoid bias toward long sentences
|
||||
if len(words) > 0:
|
||||
score = score / (len(words) ** 0.5) # Square root normalization
|
||||
|
||||
sentence_scores[sentence] = score
|
||||
|
||||
return sentence_scores
|
||||
"""
|
||||
Summarizer Service Module
|
||||
Extractive text summarization for video transcripts
|
||||
"""
|
||||
import re
|
||||
import heapq
|
||||
import logging
|
||||
from typing import List
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Stop words for summarization
|
||||
STOP_WORDS = frozenset([
|
||||
'the', 'a', 'an', 'and', 'or', 'but', 'is', 'are', 'was', 'were',
|
||||
'to', 'of', 'in', 'on', 'at', 'for', 'with', 'that', 'this', 'it',
|
||||
'you', 'i', 'we', 'they', 'he', 'she', 'be', 'have', 'has', 'do',
|
||||
'does', 'did', 'will', 'would', 'could', 'should', 'may', 'might',
|
||||
'must', 'can', 'not', 'no', 'so', 'as', 'if', 'then', 'than',
|
||||
'when', 'where', 'what', 'which', 'who', 'how', 'why', 'all',
|
||||
'each', 'every', 'both', 'few', 'more', 'most', 'other', 'some',
|
||||
'such', 'any', 'only', 'own', 'same', 'just', 'now', 'also', 'very'
|
||||
])
|
||||
|
||||
|
||||
def extractive_summary(text: str, num_sentences: int = 5) -> str:
|
||||
"""
|
||||
Generate an extractive summary of text
|
||||
|
||||
Args:
|
||||
text: Input text to summarize
|
||||
num_sentences: Number of sentences to extract
|
||||
|
||||
Returns:
|
||||
Summary string with top-ranked sentences
|
||||
"""
|
||||
if not text or not text.strip():
|
||||
return "Not enough content to summarize."
|
||||
|
||||
# Clean text - remove metadata like [Music] common in auto-captions
|
||||
clean_text = re.sub(r'\[.*?\]', '', text)
|
||||
clean_text = clean_text.replace('\n', ' ')
|
||||
clean_text = re.sub(r'\s+', ' ', clean_text).strip()
|
||||
|
||||
if len(clean_text) < 100:
|
||||
return clean_text
|
||||
|
||||
# Split into sentences
|
||||
sentences = _split_sentences(clean_text)
|
||||
|
||||
if len(sentences) <= num_sentences:
|
||||
return clean_text
|
||||
|
||||
# Calculate word frequencies
|
||||
word_frequencies = _calculate_word_frequencies(clean_text)
|
||||
|
||||
if not word_frequencies:
|
||||
return "Not enough content to summarize."
|
||||
|
||||
# Score sentences
|
||||
sentence_scores = _score_sentences(sentences, word_frequencies)
|
||||
|
||||
# Extract top N sentences
|
||||
top_sentences = heapq.nlargest(num_sentences, sentence_scores, key=sentence_scores.get)
|
||||
|
||||
# Return in original order
|
||||
ordered = [s for s in sentences if s in top_sentences]
|
||||
|
||||
return ' '.join(ordered)
|
||||
|
||||
|
||||
def _split_sentences(text: str) -> List[str]:
|
||||
"""Split text into sentences"""
|
||||
# Regex for sentence splitting - handles abbreviations
|
||||
pattern = r'(?<!\w\.\w.)(?<![A-Z][a-z]\.)(?<=\.|\?|\!)\s'
|
||||
sentences = re.split(pattern, text)
|
||||
|
||||
# Filter out very short sentences
|
||||
return [s.strip() for s in sentences if len(s.strip()) > 20]
|
||||
|
||||
|
||||
def _calculate_word_frequencies(text: str) -> dict:
|
||||
"""Calculate normalized word frequencies"""
|
||||
word_frequencies = {}
|
||||
|
||||
words = re.findall(r'\w+', text.lower())
|
||||
|
||||
for word in words:
|
||||
if word not in STOP_WORDS and len(word) > 2:
|
||||
word_frequencies[word] = word_frequencies.get(word, 0) + 1
|
||||
|
||||
if not word_frequencies:
|
||||
return {}
|
||||
|
||||
# Normalize by max frequency
|
||||
max_freq = max(word_frequencies.values())
|
||||
for word in word_frequencies:
|
||||
word_frequencies[word] = word_frequencies[word] / max_freq
|
||||
|
||||
return word_frequencies
|
||||
|
||||
|
||||
def _score_sentences(sentences: List[str], word_frequencies: dict) -> dict:
|
||||
"""Score sentences based on word frequencies"""
|
||||
sentence_scores = {}
|
||||
|
||||
for sentence in sentences:
|
||||
words = re.findall(r'\w+', sentence.lower())
|
||||
score = sum(word_frequencies.get(word, 0) for word in words)
|
||||
|
||||
# Normalize by sentence length to avoid bias toward long sentences
|
||||
if len(words) > 0:
|
||||
score = score / (len(words) ** 0.5) # Square root normalization
|
||||
|
||||
sentence_scores[sentence] = score
|
||||
|
||||
return sentence_scores
|
||||
|
|
|
|||
560
app/services/youtube.py
Normal file → Executable file
560
app/services/youtube.py
Normal file → Executable file
|
|
@ -1,280 +1,280 @@
|
|||
"""
|
||||
YouTube Service Module
|
||||
Handles all yt-dlp interactions using the library directly (not subprocess)
|
||||
"""
|
||||
import yt_dlp
|
||||
import logging
|
||||
from typing import Optional, List, Dict, Any
|
||||
from config import Config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class YouTubeService:
|
||||
"""Service for fetching YouTube content using yt-dlp library"""
|
||||
|
||||
# Common yt-dlp options
|
||||
BASE_OPTS = {
|
||||
'quiet': True,
|
||||
'no_warnings': True,
|
||||
'extract_flat': 'in_playlist',
|
||||
'force_ipv4': True,
|
||||
'socket_timeout': Config.YTDLP_TIMEOUT,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def sanitize_video_data(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Sanitize and format video data from yt-dlp"""
|
||||
video_id = data.get('id', '')
|
||||
duration_secs = data.get('duration')
|
||||
|
||||
# Format duration
|
||||
duration_str = None
|
||||
if duration_secs:
|
||||
mins, secs = divmod(int(duration_secs), 60)
|
||||
hours, mins = divmod(mins, 60)
|
||||
duration_str = f"{hours}:{mins:02d}:{secs:02d}" if hours else f"{mins}:{secs:02d}"
|
||||
|
||||
return {
|
||||
'id': video_id,
|
||||
'title': data.get('title', 'Unknown'),
|
||||
'uploader': data.get('uploader') or data.get('channel') or 'Unknown',
|
||||
'channel_id': data.get('channel_id'),
|
||||
'uploader_id': data.get('uploader_id'),
|
||||
'thumbnail': f"https://i.ytimg.com/vi/{video_id}/hqdefault.jpg" if video_id else None,
|
||||
'view_count': data.get('view_count', 0),
|
||||
'upload_date': data.get('upload_date', ''),
|
||||
'duration': duration_str,
|
||||
'description': data.get('description', ''),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def search_videos(cls, query: str, limit: int = 20, filter_type: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Search for videos using yt-dlp library directly
|
||||
|
||||
Args:
|
||||
query: Search query
|
||||
limit: Maximum number of results
|
||||
filter_type: 'video' to exclude shorts, 'short' for only shorts
|
||||
|
||||
Returns:
|
||||
List of sanitized video data dictionaries
|
||||
"""
|
||||
try:
|
||||
search_url = f"ytsearch{limit}:{query}"
|
||||
|
||||
ydl_opts = {
|
||||
**cls.BASE_OPTS,
|
||||
'extract_flat': True,
|
||||
'playlist_items': f'1:{limit}',
|
||||
}
|
||||
|
||||
results = []
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||
info = ydl.extract_info(search_url, download=False)
|
||||
entries = info.get('entries', []) if info else []
|
||||
|
||||
for entry in entries:
|
||||
if not entry or not entry.get('id'):
|
||||
continue
|
||||
|
||||
# Filter logic
|
||||
title_lower = (entry.get('title') or '').lower()
|
||||
duration_secs = entry.get('duration')
|
||||
|
||||
if filter_type == 'video':
|
||||
# Exclude shorts
|
||||
if '#shorts' in title_lower:
|
||||
continue
|
||||
if duration_secs and int(duration_secs) <= 70:
|
||||
continue
|
||||
elif filter_type == 'short':
|
||||
# Only shorts
|
||||
if duration_secs and int(duration_secs) > 60:
|
||||
continue
|
||||
|
||||
results.append(cls.sanitize_video_data(entry))
|
||||
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Search error for '{query}': {e}")
|
||||
return []
|
||||
|
||||
@classmethod
|
||||
def get_video_info(cls, video_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get detailed video information including stream URL
|
||||
|
||||
Args:
|
||||
video_id: YouTube video ID
|
||||
|
||||
Returns:
|
||||
Video info dict with stream_url, or None on error
|
||||
"""
|
||||
try:
|
||||
url = f"https://www.youtube.com/watch?v={video_id}"
|
||||
|
||||
ydl_opts = {
|
||||
**cls.BASE_OPTS,
|
||||
'format': Config.YTDLP_FORMAT,
|
||||
'noplaylist': True,
|
||||
'skip_download': True,
|
||||
}
|
||||
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||
info = ydl.extract_info(url, download=False)
|
||||
|
||||
if not info:
|
||||
return None
|
||||
|
||||
stream_url = info.get('url')
|
||||
if not stream_url:
|
||||
logger.warning(f"No stream URL found for {video_id}")
|
||||
return None
|
||||
|
||||
# Get subtitles
|
||||
subtitle_url = cls._extract_subtitle_url(info)
|
||||
|
||||
return {
|
||||
'stream_url': stream_url,
|
||||
'title': info.get('title', 'Unknown'),
|
||||
'description': info.get('description', ''),
|
||||
'uploader': info.get('uploader', ''),
|
||||
'uploader_id': info.get('uploader_id', ''),
|
||||
'channel_id': info.get('channel_id', ''),
|
||||
'upload_date': info.get('upload_date', ''),
|
||||
'view_count': info.get('view_count', 0),
|
||||
'subtitle_url': subtitle_url,
|
||||
'duration': info.get('duration'),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting video info for {video_id}: {e}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _extract_subtitle_url(info: Dict[str, Any]) -> Optional[str]:
|
||||
"""Extract best subtitle URL from video info"""
|
||||
subs = info.get('subtitles') or {}
|
||||
auto_subs = info.get('automatic_captions') or {}
|
||||
|
||||
# Priority: en manual > vi manual > en auto > vi auto > first available
|
||||
for lang in ['en', 'vi']:
|
||||
if lang in subs and subs[lang]:
|
||||
return subs[lang][0].get('url')
|
||||
|
||||
for lang in ['en', 'vi']:
|
||||
if lang in auto_subs and auto_subs[lang]:
|
||||
return auto_subs[lang][0].get('url')
|
||||
|
||||
# Fallback to first available
|
||||
if subs:
|
||||
first_key = list(subs.keys())[0]
|
||||
if subs[first_key]:
|
||||
return subs[first_key][0].get('url')
|
||||
|
||||
if auto_subs:
|
||||
first_key = list(auto_subs.keys())[0]
|
||||
if auto_subs[first_key]:
|
||||
return auto_subs[first_key][0].get('url')
|
||||
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def get_channel_videos(cls, channel_id: str, limit: int = 20) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get videos from a YouTube channel
|
||||
|
||||
Args:
|
||||
channel_id: Channel ID, handle (@username), or URL
|
||||
limit: Maximum number of videos
|
||||
|
||||
Returns:
|
||||
List of video data dictionaries
|
||||
"""
|
||||
try:
|
||||
# Construct URL based on ID format
|
||||
if channel_id.startswith('http'):
|
||||
url = channel_id
|
||||
elif channel_id.startswith('@'):
|
||||
url = f"https://www.youtube.com/{channel_id}"
|
||||
elif len(channel_id) == 24 and channel_id.startswith('UC'):
|
||||
url = f"https://www.youtube.com/channel/{channel_id}"
|
||||
else:
|
||||
url = f"https://www.youtube.com/{channel_id}"
|
||||
|
||||
ydl_opts = {
|
||||
**cls.BASE_OPTS,
|
||||
'extract_flat': True,
|
||||
'playlist_items': f'1:{limit}',
|
||||
}
|
||||
|
||||
results = []
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||
info = ydl.extract_info(url, download=False)
|
||||
entries = info.get('entries', []) if info else []
|
||||
|
||||
for entry in entries:
|
||||
if entry and entry.get('id'):
|
||||
results.append(cls.sanitize_video_data(entry))
|
||||
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting channel videos for {channel_id}: {e}")
|
||||
return []
|
||||
|
||||
@classmethod
|
||||
def get_related_videos(cls, title: str, limit: int = 10) -> List[Dict[str, Any]]:
|
||||
"""Get videos related to a given title"""
|
||||
query = f"{title} related"
|
||||
return cls.search_videos(query, limit=limit, filter_type='video')
|
||||
|
||||
@classmethod
|
||||
def get_download_url(cls, video_id: str) -> Optional[Dict[str, str]]:
|
||||
"""
|
||||
Get direct download URL (non-HLS) for a video
|
||||
|
||||
Returns:
|
||||
Dict with 'url', 'title', 'ext' or None
|
||||
"""
|
||||
try:
|
||||
url = f"https://www.youtube.com/watch?v={video_id}"
|
||||
|
||||
ydl_opts = {
|
||||
**cls.BASE_OPTS,
|
||||
'format': 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best[protocol!*=m3u8]/best',
|
||||
'noplaylist': True,
|
||||
'skip_download': True,
|
||||
'youtube_include_dash_manifest': False,
|
||||
'youtube_include_hls_manifest': False,
|
||||
}
|
||||
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||
info = ydl.extract_info(url, download=False)
|
||||
|
||||
download_url = info.get('url', '')
|
||||
|
||||
# If m3u8, try to find non-HLS format
|
||||
if '.m3u8' in download_url or not download_url:
|
||||
formats = info.get('formats', [])
|
||||
for f in reversed(formats):
|
||||
f_url = f.get('url', '')
|
||||
if f_url and 'm3u8' not in f_url and f.get('ext') == 'mp4':
|
||||
download_url = f_url
|
||||
break
|
||||
|
||||
if download_url and '.m3u8' not in download_url:
|
||||
return {
|
||||
'url': download_url,
|
||||
'title': info.get('title', 'video'),
|
||||
'ext': 'mp4'
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting download URL for {video_id}: {e}")
|
||||
return None
|
||||
"""
|
||||
YouTube Service Module
|
||||
Handles all yt-dlp interactions using the library directly (not subprocess)
|
||||
"""
|
||||
import yt_dlp
|
||||
import logging
|
||||
from typing import Optional, List, Dict, Any
|
||||
from config import Config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class YouTubeService:
|
||||
"""Service for fetching YouTube content using yt-dlp library"""
|
||||
|
||||
# Common yt-dlp options
|
||||
BASE_OPTS = {
|
||||
'quiet': True,
|
||||
'no_warnings': True,
|
||||
'extract_flat': 'in_playlist',
|
||||
'force_ipv4': True,
|
||||
'socket_timeout': Config.YTDLP_TIMEOUT,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def sanitize_video_data(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Sanitize and format video data from yt-dlp"""
|
||||
video_id = data.get('id', '')
|
||||
duration_secs = data.get('duration')
|
||||
|
||||
# Format duration
|
||||
duration_str = None
|
||||
if duration_secs:
|
||||
mins, secs = divmod(int(duration_secs), 60)
|
||||
hours, mins = divmod(mins, 60)
|
||||
duration_str = f"{hours}:{mins:02d}:{secs:02d}" if hours else f"{mins}:{secs:02d}"
|
||||
|
||||
return {
|
||||
'id': video_id,
|
||||
'title': data.get('title', 'Unknown'),
|
||||
'uploader': data.get('uploader') or data.get('channel') or 'Unknown',
|
||||
'channel_id': data.get('channel_id'),
|
||||
'uploader_id': data.get('uploader_id'),
|
||||
'thumbnail': f"https://i.ytimg.com/vi/{video_id}/hqdefault.jpg" if video_id else None,
|
||||
'view_count': data.get('view_count', 0),
|
||||
'upload_date': data.get('upload_date', ''),
|
||||
'duration': duration_str,
|
||||
'description': data.get('description', ''),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def search_videos(cls, query: str, limit: int = 20, filter_type: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Search for videos using yt-dlp library directly
|
||||
|
||||
Args:
|
||||
query: Search query
|
||||
limit: Maximum number of results
|
||||
filter_type: 'video' to exclude shorts, 'short' for only shorts
|
||||
|
||||
Returns:
|
||||
List of sanitized video data dictionaries
|
||||
"""
|
||||
try:
|
||||
search_url = f"ytsearch{limit}:{query}"
|
||||
|
||||
ydl_opts = {
|
||||
**cls.BASE_OPTS,
|
||||
'extract_flat': True,
|
||||
'playlist_items': f'1:{limit}',
|
||||
}
|
||||
|
||||
results = []
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||
info = ydl.extract_info(search_url, download=False)
|
||||
entries = info.get('entries', []) if info else []
|
||||
|
||||
for entry in entries:
|
||||
if not entry or not entry.get('id'):
|
||||
continue
|
||||
|
||||
# Filter logic
|
||||
title_lower = (entry.get('title') or '').lower()
|
||||
duration_secs = entry.get('duration')
|
||||
|
||||
if filter_type == 'video':
|
||||
# Exclude shorts
|
||||
if '#shorts' in title_lower:
|
||||
continue
|
||||
if duration_secs and int(duration_secs) <= 70:
|
||||
continue
|
||||
elif filter_type == 'short':
|
||||
# Only shorts
|
||||
if duration_secs and int(duration_secs) > 60:
|
||||
continue
|
||||
|
||||
results.append(cls.sanitize_video_data(entry))
|
||||
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Search error for '{query}': {e}")
|
||||
return []
|
||||
|
||||
@classmethod
|
||||
def get_video_info(cls, video_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get detailed video information including stream URL
|
||||
|
||||
Args:
|
||||
video_id: YouTube video ID
|
||||
|
||||
Returns:
|
||||
Video info dict with stream_url, or None on error
|
||||
"""
|
||||
try:
|
||||
url = f"https://www.youtube.com/watch?v={video_id}"
|
||||
|
||||
ydl_opts = {
|
||||
**cls.BASE_OPTS,
|
||||
'format': Config.YTDLP_FORMAT,
|
||||
'noplaylist': True,
|
||||
'skip_download': True,
|
||||
}
|
||||
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||
info = ydl.extract_info(url, download=False)
|
||||
|
||||
if not info:
|
||||
return None
|
||||
|
||||
stream_url = info.get('url')
|
||||
if not stream_url:
|
||||
logger.warning(f"No stream URL found for {video_id}")
|
||||
return None
|
||||
|
||||
# Get subtitles
|
||||
subtitle_url = cls._extract_subtitle_url(info)
|
||||
|
||||
return {
|
||||
'stream_url': stream_url,
|
||||
'title': info.get('title', 'Unknown'),
|
||||
'description': info.get('description', ''),
|
||||
'uploader': info.get('uploader', ''),
|
||||
'uploader_id': info.get('uploader_id', ''),
|
||||
'channel_id': info.get('channel_id', ''),
|
||||
'upload_date': info.get('upload_date', ''),
|
||||
'view_count': info.get('view_count', 0),
|
||||
'subtitle_url': subtitle_url,
|
||||
'duration': info.get('duration'),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting video info for {video_id}: {e}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _extract_subtitle_url(info: Dict[str, Any]) -> Optional[str]:
|
||||
"""Extract best subtitle URL from video info"""
|
||||
subs = info.get('subtitles') or {}
|
||||
auto_subs = info.get('automatic_captions') or {}
|
||||
|
||||
# Priority: en manual > vi manual > en auto > vi auto > first available
|
||||
for lang in ['en', 'vi']:
|
||||
if lang in subs and subs[lang]:
|
||||
return subs[lang][0].get('url')
|
||||
|
||||
for lang in ['en', 'vi']:
|
||||
if lang in auto_subs and auto_subs[lang]:
|
||||
return auto_subs[lang][0].get('url')
|
||||
|
||||
# Fallback to first available
|
||||
if subs:
|
||||
first_key = list(subs.keys())[0]
|
||||
if subs[first_key]:
|
||||
return subs[first_key][0].get('url')
|
||||
|
||||
if auto_subs:
|
||||
first_key = list(auto_subs.keys())[0]
|
||||
if auto_subs[first_key]:
|
||||
return auto_subs[first_key][0].get('url')
|
||||
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def get_channel_videos(cls, channel_id: str, limit: int = 20) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get videos from a YouTube channel
|
||||
|
||||
Args:
|
||||
channel_id: Channel ID, handle (@username), or URL
|
||||
limit: Maximum number of videos
|
||||
|
||||
Returns:
|
||||
List of video data dictionaries
|
||||
"""
|
||||
try:
|
||||
# Construct URL based on ID format
|
||||
if channel_id.startswith('http'):
|
||||
url = channel_id
|
||||
elif channel_id.startswith('@'):
|
||||
url = f"https://www.youtube.com/{channel_id}"
|
||||
elif len(channel_id) == 24 and channel_id.startswith('UC'):
|
||||
url = f"https://www.youtube.com/channel/{channel_id}"
|
||||
else:
|
||||
url = f"https://www.youtube.com/{channel_id}"
|
||||
|
||||
ydl_opts = {
|
||||
**cls.BASE_OPTS,
|
||||
'extract_flat': True,
|
||||
'playlist_items': f'1:{limit}',
|
||||
}
|
||||
|
||||
results = []
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||
info = ydl.extract_info(url, download=False)
|
||||
entries = info.get('entries', []) if info else []
|
||||
|
||||
for entry in entries:
|
||||
if entry and entry.get('id'):
|
||||
results.append(cls.sanitize_video_data(entry))
|
||||
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting channel videos for {channel_id}: {e}")
|
||||
return []
|
||||
|
||||
@classmethod
|
||||
def get_related_videos(cls, title: str, limit: int = 10) -> List[Dict[str, Any]]:
|
||||
"""Get videos related to a given title"""
|
||||
query = f"{title} related"
|
||||
return cls.search_videos(query, limit=limit, filter_type='video')
|
||||
|
||||
@classmethod
|
||||
def get_download_url(cls, video_id: str) -> Optional[Dict[str, str]]:
|
||||
"""
|
||||
Get direct download URL (non-HLS) for a video
|
||||
|
||||
Returns:
|
||||
Dict with 'url', 'title', 'ext' or None
|
||||
"""
|
||||
try:
|
||||
url = f"https://www.youtube.com/watch?v={video_id}"
|
||||
|
||||
ydl_opts = {
|
||||
**cls.BASE_OPTS,
|
||||
'format': 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best[protocol!*=m3u8]/best',
|
||||
'noplaylist': True,
|
||||
'skip_download': True,
|
||||
'youtube_include_dash_manifest': False,
|
||||
'youtube_include_hls_manifest': False,
|
||||
}
|
||||
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||
info = ydl.extract_info(url, download=False)
|
||||
|
||||
download_url = info.get('url', '')
|
||||
|
||||
# If m3u8, try to find non-HLS format
|
||||
if '.m3u8' in download_url or not download_url:
|
||||
formats = info.get('formats', [])
|
||||
for f in reversed(formats):
|
||||
f_url = f.get('url', '')
|
||||
if f_url and 'm3u8' not in f_url and f.get('ext') == 'mp4':
|
||||
download_url = f_url
|
||||
break
|
||||
|
||||
if download_url and '.m3u8' not in download_url:
|
||||
return {
|
||||
'url': download_url,
|
||||
'title': info.get('title', 'video'),
|
||||
'ext': 'mp4'
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting download URL for {video_id}: {e}")
|
||||
return None
|
||||
|
|
|
|||
2
app/utils/__init__.py
Normal file → Executable file
2
app/utils/__init__.py
Normal file → Executable file
|
|
@ -1 +1 @@
|
|||
"""KV-Tube Utilities Package"""
|
||||
"""KV-Tube Utilities Package"""
|
||||
|
|
|
|||
190
app/utils/formatters.py
Normal file → Executable file
190
app/utils/formatters.py
Normal file → Executable file
|
|
@ -1,95 +1,95 @@
|
|||
"""
|
||||
Template Formatters Module
|
||||
Jinja2 template filters for formatting views and dates
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
|
||||
def format_views(views) -> str:
|
||||
"""Format view count (YouTube style: 1.2M, 3.5K)"""
|
||||
if not views:
|
||||
return '0'
|
||||
try:
|
||||
num = int(views)
|
||||
if num >= 1_000_000_000:
|
||||
return f"{num / 1_000_000_000:.1f}B"
|
||||
if num >= 1_000_000:
|
||||
return f"{num / 1_000_000:.1f}M"
|
||||
if num >= 1_000:
|
||||
return f"{num / 1_000:.0f}K"
|
||||
return f"{num:,}"
|
||||
except (ValueError, TypeError):
|
||||
return str(views)
|
||||
|
||||
|
||||
def format_date(value) -> str:
|
||||
"""Format date to relative time (YouTube style: 2 hours ago, 3 days ago)"""
|
||||
if not value:
|
||||
return 'Recently'
|
||||
|
||||
try:
|
||||
# Handle YYYYMMDD format
|
||||
if len(str(value)) == 8 and str(value).isdigit():
|
||||
dt = datetime.strptime(str(value), '%Y%m%d')
|
||||
# Handle timestamp
|
||||
elif isinstance(value, (int, float)):
|
||||
dt = datetime.fromtimestamp(value)
|
||||
# Handle datetime object
|
||||
elif isinstance(value, datetime):
|
||||
dt = value
|
||||
# Handle YYYY-MM-DD string
|
||||
else:
|
||||
try:
|
||||
dt = datetime.strptime(str(value), '%Y-%m-%d')
|
||||
except ValueError:
|
||||
return str(value)
|
||||
|
||||
now = datetime.now()
|
||||
diff = now - dt
|
||||
|
||||
if diff.days > 365:
|
||||
years = diff.days // 365
|
||||
return f"{years} year{'s' if years > 1 else ''} ago"
|
||||
if diff.days > 30:
|
||||
months = diff.days // 30
|
||||
return f"{months} month{'s' if months > 1 else ''} ago"
|
||||
if diff.days > 7:
|
||||
weeks = diff.days // 7
|
||||
return f"{weeks} week{'s' if weeks > 1 else ''} ago"
|
||||
if diff.days > 0:
|
||||
return f"{diff.days} day{'s' if diff.days > 1 else ''} ago"
|
||||
if diff.seconds > 3600:
|
||||
hours = diff.seconds // 3600
|
||||
return f"{hours} hour{'s' if hours > 1 else ''} ago"
|
||||
if diff.seconds > 60:
|
||||
minutes = diff.seconds // 60
|
||||
return f"{minutes} minute{'s' if minutes > 1 else ''} ago"
|
||||
return "Just now"
|
||||
|
||||
except Exception:
|
||||
return str(value)
|
||||
|
||||
|
||||
def format_duration(seconds) -> str:
|
||||
"""Format duration in seconds to HH:MM:SS or MM:SS"""
|
||||
if not seconds:
|
||||
return ''
|
||||
|
||||
try:
|
||||
secs = int(seconds)
|
||||
mins, secs = divmod(secs, 60)
|
||||
hours, mins = divmod(mins, 60)
|
||||
|
||||
if hours:
|
||||
return f"{hours}:{mins:02d}:{secs:02d}"
|
||||
return f"{mins}:{secs:02d}"
|
||||
|
||||
except (ValueError, TypeError):
|
||||
return ''
|
||||
|
||||
|
||||
def register_filters(app):
|
||||
"""Register all template filters with Flask app"""
|
||||
app.template_filter('format_views')(format_views)
|
||||
app.template_filter('format_date')(format_date)
|
||||
app.template_filter('format_duration')(format_duration)
|
||||
"""
|
||||
Template Formatters Module
|
||||
Jinja2 template filters for formatting views and dates
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
|
||||
def format_views(views) -> str:
|
||||
"""Format view count (YouTube style: 1.2M, 3.5K)"""
|
||||
if not views:
|
||||
return '0'
|
||||
try:
|
||||
num = int(views)
|
||||
if num >= 1_000_000_000:
|
||||
return f"{num / 1_000_000_000:.1f}B"
|
||||
if num >= 1_000_000:
|
||||
return f"{num / 1_000_000:.1f}M"
|
||||
if num >= 1_000:
|
||||
return f"{num / 1_000:.0f}K"
|
||||
return f"{num:,}"
|
||||
except (ValueError, TypeError):
|
||||
return str(views)
|
||||
|
||||
|
||||
def format_date(value) -> str:
|
||||
"""Format date to relative time (YouTube style: 2 hours ago, 3 days ago)"""
|
||||
if not value:
|
||||
return 'Recently'
|
||||
|
||||
try:
|
||||
# Handle YYYYMMDD format
|
||||
if len(str(value)) == 8 and str(value).isdigit():
|
||||
dt = datetime.strptime(str(value), '%Y%m%d')
|
||||
# Handle timestamp
|
||||
elif isinstance(value, (int, float)):
|
||||
dt = datetime.fromtimestamp(value)
|
||||
# Handle datetime object
|
||||
elif isinstance(value, datetime):
|
||||
dt = value
|
||||
# Handle YYYY-MM-DD string
|
||||
else:
|
||||
try:
|
||||
dt = datetime.strptime(str(value), '%Y-%m-%d')
|
||||
except ValueError:
|
||||
return str(value)
|
||||
|
||||
now = datetime.now()
|
||||
diff = now - dt
|
||||
|
||||
if diff.days > 365:
|
||||
years = diff.days // 365
|
||||
return f"{years} year{'s' if years > 1 else ''} ago"
|
||||
if diff.days > 30:
|
||||
months = diff.days // 30
|
||||
return f"{months} month{'s' if months > 1 else ''} ago"
|
||||
if diff.days > 7:
|
||||
weeks = diff.days // 7
|
||||
return f"{weeks} week{'s' if weeks > 1 else ''} ago"
|
||||
if diff.days > 0:
|
||||
return f"{diff.days} day{'s' if diff.days > 1 else ''} ago"
|
||||
if diff.seconds > 3600:
|
||||
hours = diff.seconds // 3600
|
||||
return f"{hours} hour{'s' if hours > 1 else ''} ago"
|
||||
if diff.seconds > 60:
|
||||
minutes = diff.seconds // 60
|
||||
return f"{minutes} minute{'s' if minutes > 1 else ''} ago"
|
||||
return "Just now"
|
||||
|
||||
except Exception:
|
||||
return str(value)
|
||||
|
||||
|
||||
def format_duration(seconds) -> str:
|
||||
"""Format duration in seconds to HH:MM:SS or MM:SS"""
|
||||
if not seconds:
|
||||
return ''
|
||||
|
||||
try:
|
||||
secs = int(seconds)
|
||||
mins, secs = divmod(secs, 60)
|
||||
hours, mins = divmod(mins, 60)
|
||||
|
||||
if hours:
|
||||
return f"{hours}:{mins:02d}:{secs:02d}"
|
||||
return f"{mins}:{secs:02d}"
|
||||
|
||||
except (ValueError, TypeError):
|
||||
return ''
|
||||
|
||||
|
||||
def register_filters(app):
|
||||
"""Register all template filters with Flask app"""
|
||||
app.template_filter('format_views')(format_views)
|
||||
app.template_filter('format_date')(format_date)
|
||||
app.template_filter('format_duration')(format_duration)
|
||||
|
|
|
|||
116
config.py
Normal file → Executable file
116
config.py
Normal file → Executable file
|
|
@ -1,58 +1,58 @@
|
|||
"""
|
||||
KV-Tube Configuration Module
|
||||
Centralizes all configuration with environment variable support
|
||||
"""
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load .env file if present
|
||||
load_dotenv()
|
||||
|
||||
class Config:
|
||||
"""Base configuration"""
|
||||
SECRET_KEY = os.environ.get('SECRET_KEY', os.urandom(32).hex())
|
||||
|
||||
# Database
|
||||
DATA_DIR = os.environ.get('KVTUBE_DATA_DIR', 'data')
|
||||
DB_NAME = os.path.join(DATA_DIR, 'kvtube.db')
|
||||
|
||||
# Video storage
|
||||
VIDEO_DIR = os.environ.get('KVTUBE_VIDEO_DIR', './videos')
|
||||
|
||||
# Rate limiting
|
||||
RATELIMIT_DEFAULT = "60/minute"
|
||||
RATELIMIT_SEARCH = "30/minute"
|
||||
RATELIMIT_STREAM = "120/minute"
|
||||
|
||||
# Cache settings (in seconds)
|
||||
CACHE_VIDEO_TTL = 3600 # 1 hour
|
||||
CACHE_CHANNEL_TTL = 1800 # 30 minutes
|
||||
|
||||
# yt-dlp settings
|
||||
YTDLP_FORMAT = 'best[ext=mp4]/best'
|
||||
YTDLP_TIMEOUT = 30
|
||||
|
||||
@staticmethod
|
||||
def init_app(app):
|
||||
"""Initialize app with config"""
|
||||
# Ensure data directory exists
|
||||
os.makedirs(Config.DATA_DIR, exist_ok=True)
|
||||
|
||||
|
||||
class DevelopmentConfig(Config):
|
||||
"""Development configuration"""
|
||||
DEBUG = True
|
||||
FLASK_ENV = 'development'
|
||||
|
||||
|
||||
class ProductionConfig(Config):
|
||||
"""Production configuration"""
|
||||
DEBUG = False
|
||||
FLASK_ENV = 'production'
|
||||
|
||||
|
||||
config = {
|
||||
'development': DevelopmentConfig,
|
||||
'production': ProductionConfig,
|
||||
'default': DevelopmentConfig
|
||||
}
|
||||
"""
|
||||
KV-Tube Configuration Module
|
||||
Centralizes all configuration with environment variable support
|
||||
"""
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load .env file if present
|
||||
load_dotenv()
|
||||
|
||||
class Config:
|
||||
"""Base configuration"""
|
||||
SECRET_KEY = os.environ.get('SECRET_KEY', os.urandom(32).hex())
|
||||
|
||||
# Database
|
||||
DATA_DIR = os.environ.get('KVTUBE_DATA_DIR', 'data')
|
||||
DB_NAME = os.path.join(DATA_DIR, 'kvtube.db')
|
||||
|
||||
# Video storage
|
||||
VIDEO_DIR = os.environ.get('KVTUBE_VIDEO_DIR', './videos')
|
||||
|
||||
# Rate limiting
|
||||
RATELIMIT_DEFAULT = "60/minute"
|
||||
RATELIMIT_SEARCH = "30/minute"
|
||||
RATELIMIT_STREAM = "120/minute"
|
||||
|
||||
# Cache settings (in seconds)
|
||||
CACHE_VIDEO_TTL = 3600 # 1 hour
|
||||
CACHE_CHANNEL_TTL = 1800 # 30 minutes
|
||||
|
||||
# yt-dlp settings
|
||||
YTDLP_FORMAT = 'best[ext=mp4]/best'
|
||||
YTDLP_TIMEOUT = 30
|
||||
|
||||
@staticmethod
|
||||
def init_app(app):
|
||||
"""Initialize app with config"""
|
||||
# Ensure data directory exists
|
||||
os.makedirs(Config.DATA_DIR, exist_ok=True)
|
||||
|
||||
|
||||
class DevelopmentConfig(Config):
|
||||
"""Development configuration"""
|
||||
DEBUG = True
|
||||
FLASK_ENV = 'development'
|
||||
|
||||
|
||||
class ProductionConfig(Config):
|
||||
"""Production configuration"""
|
||||
DEBUG = False
|
||||
FLASK_ENV = 'production'
|
||||
|
||||
|
||||
config = {
|
||||
'development': DevelopmentConfig,
|
||||
'production': ProductionConfig,
|
||||
'default': DevelopmentConfig
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,94 +0,0 @@
|
|||
import yt_dlp
|
||||
import requests
|
||||
import json
|
||||
import traceback
|
||||
|
||||
def _parse_json3_subtitles(data):
|
||||
"""Parse YouTube json3 subtitle format into simplified format"""
|
||||
transcript = []
|
||||
events = data.get('events', [])
|
||||
|
||||
for event in events:
|
||||
# Skip non-text events
|
||||
if 'segs' not in event:
|
||||
continue
|
||||
|
||||
start_ms = event.get('tStartMs', 0)
|
||||
duration_ms = event.get('dDurationMs', 0)
|
||||
|
||||
# Combine all segments in this event
|
||||
text_parts = []
|
||||
for seg in event.get('segs', []):
|
||||
text = seg.get('utf8', '')
|
||||
if text and text.strip():
|
||||
text_parts.append(text)
|
||||
|
||||
combined_text = ''.join(text_parts).strip()
|
||||
if combined_text:
|
||||
transcript.append({
|
||||
'text': combined_text,
|
||||
'start': start_ms / 1000.0, # Convert to seconds
|
||||
'duration': duration_ms / 1000.0 if duration_ms else 2.0 # Default 2s
|
||||
})
|
||||
|
||||
return transcript
|
||||
|
||||
def debug(video_id):
|
||||
print(f"DEBUGGING VIDEO: {video_id}")
|
||||
url = f"https://www.youtube.com/watch?v={video_id}"
|
||||
languages = ['en', 'vi']
|
||||
|
||||
# Use a temp filename template
|
||||
import os
|
||||
temp_template = f"temp_subs_{video_id}"
|
||||
|
||||
ydl_opts = {
|
||||
'quiet': True,
|
||||
'no_warnings': True,
|
||||
'skip_download': True,
|
||||
'writesubtitles': True,
|
||||
'writeautomaticsub': True,
|
||||
'subtitleslangs': languages,
|
||||
'subtitlesformat': 'json3',
|
||||
'outtmpl': temp_template,
|
||||
}
|
||||
|
||||
try:
|
||||
# cleanup old files
|
||||
for f in os.listdir('.'):
|
||||
if f.startswith(temp_template):
|
||||
os.remove(f)
|
||||
|
||||
print("Downloading subtitles via yt-dlp...")
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||
# We must enable download=True for it to write files, but skip_download=True in opts prevents video DL
|
||||
ydl.download([url])
|
||||
|
||||
# Find the downloaded file
|
||||
downloaded_file = None
|
||||
for f in os.listdir('.'):
|
||||
if f.startswith(temp_template) and f.endswith('.json3'):
|
||||
downloaded_file = f
|
||||
break
|
||||
|
||||
if downloaded_file:
|
||||
print(f"Downloaded file: {downloaded_file}")
|
||||
with open(downloaded_file, 'r', encoding='utf-8') as f:
|
||||
sub_data = json.load(f)
|
||||
transcript_data = _parse_json3_subtitles(sub_data)
|
||||
print(f"Parsed {len(transcript_data)} items")
|
||||
# print(f"First 3: {transcript_data[:3]}")
|
||||
|
||||
# Cleanup
|
||||
os.remove(downloaded_file)
|
||||
else:
|
||||
print("No subtitle file found after download attempt.")
|
||||
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
|
||||
if __name__ == '__main__':
|
||||
debug('dQw4w9WgXcQ')
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
@echo off
|
||||
REM deploy-docker.bat - Build and push KV-Tube to Docker Hub
|
||||
|
||||
set DOCKER_USER=vndangkhoa
|
||||
set IMAGE_NAME=kvtube
|
||||
set TAG=latest
|
||||
set FULL_IMAGE=%DOCKER_USER%/%IMAGE_NAME%:%TAG%
|
||||
|
||||
echo ========================================
|
||||
echo KV-Tube Docker Deployment Script
|
||||
echo ========================================
|
||||
echo.
|
||||
|
||||
REM Step 1: Check Docker
|
||||
echo [1/4] Checking Docker...
|
||||
docker info >nul 2>&1
|
||||
if %errorlevel% neq 0 (
|
||||
echo X Docker is not running. Please start Docker Desktop.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo OK Docker is running
|
||||
|
||||
REM Step 2: Build Image
|
||||
echo.
|
||||
echo [2/4] Building Docker image: %FULL_IMAGE%
|
||||
docker build --no-cache -t %FULL_IMAGE% .
|
||||
if %errorlevel% neq 0 (
|
||||
echo X Build failed!
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo OK Build successful
|
||||
|
||||
REM Step 3: Login to Docker Hub
|
||||
echo.
|
||||
echo [3/4] Logging into Docker Hub...
|
||||
docker login
|
||||
if %errorlevel% neq 0 (
|
||||
echo X Login failed!
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo OK Login successful
|
||||
|
||||
REM Step 4: Push Image
|
||||
echo.
|
||||
echo [4/4] Pushing to Docker Hub...
|
||||
docker push %FULL_IMAGE%
|
||||
if %errorlevel% neq 0 (
|
||||
echo X Push failed!
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo OK Push successful
|
||||
|
||||
echo.
|
||||
echo ========================================
|
||||
echo Deployment Complete!
|
||||
echo Image: %FULL_IMAGE%
|
||||
echo URL: https://hub.docker.com/r/%DOCKER_USER%/%IMAGE_NAME%
|
||||
echo ========================================
|
||||
echo.
|
||||
echo To run: docker run -p 5001:5001 %FULL_IMAGE%
|
||||
echo.
|
||||
pause
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
#!/usr/bin/env pwsh
|
||||
# deploy-docker.ps1 - Build and push KV-Tube to Docker Hub
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$DOCKER_USER = "vndangkhoa"
|
||||
$IMAGE_NAME = "kvtube"
|
||||
$TAG = "latest"
|
||||
$FULL_IMAGE = "${DOCKER_USER}/${IMAGE_NAME}:${TAG}"
|
||||
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
Write-Host " KV-Tube Docker Deployment Script" -ForegroundColor Cyan
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
|
||||
# Step 1: Check Docker
|
||||
Write-Host "[1/4] Checking Docker..." -ForegroundColor Yellow
|
||||
try {
|
||||
docker info | Out-Null
|
||||
Write-Host " ✓ Docker is running" -ForegroundColor Green
|
||||
} catch {
|
||||
Write-Host " ✗ Docker is not running. Please start Docker Desktop." -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Step 2: Build Image
|
||||
Write-Host ""
|
||||
Write-Host "[2/4] Building Docker image: $FULL_IMAGE" -ForegroundColor Yellow
|
||||
docker build --no-cache -t $FULL_IMAGE .
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host " ✗ Build failed!" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
Write-Host " ✓ Build successful" -ForegroundColor Green
|
||||
|
||||
# Step 3: Login to Docker Hub
|
||||
Write-Host ""
|
||||
Write-Host "[3/4] Logging into Docker Hub..." -ForegroundColor Yellow
|
||||
docker login
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host " ✗ Login failed!" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
Write-Host " ✓ Login successful" -ForegroundColor Green
|
||||
|
||||
# Step 4: Push Image
|
||||
Write-Host ""
|
||||
Write-Host "[4/4] Pushing to Docker Hub..." -ForegroundColor Yellow
|
||||
docker push $FULL_IMAGE
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host " ✗ Push failed!" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
Write-Host " ✓ Push successful" -ForegroundColor Green
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
Write-Host " Deployment Complete!" -ForegroundColor Cyan
|
||||
Write-Host " Image: $FULL_IMAGE" -ForegroundColor Cyan
|
||||
Write-Host " URL: https://hub.docker.com/r/${DOCKER_USER}/${IMAGE_NAME}" -ForegroundColor Cyan
|
||||
Write-Host "========================================" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
Write-Host "To run: docker run -p 5001:5001 $FULL_IMAGE" -ForegroundColor White
|
||||
107
deploy.py
Normal file
107
deploy.py
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
KV-Tube Deployment Script
|
||||
Handles cleanup, git operations, and provides docker commands.
|
||||
Run this with: exec(open("deploy.py").read())
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
# Files to remove
|
||||
files_to_remove = [
|
||||
"server.log",
|
||||
"response.json",
|
||||
"response_error.json",
|
||||
"proxy_check.m3u8",
|
||||
"nul",
|
||||
"CONSOLE_ERROR_FIXES.md",
|
||||
"DOWNLOAD_FIXES.md",
|
||||
"TEST_REPORT.md",
|
||||
"debug_transcript.py",
|
||||
"generate_icons.py",
|
||||
"deploy-docker.bat",
|
||||
"deploy-docker.ps1",
|
||||
"deploy_v2.ps1",
|
||||
"cleanup.py",
|
||||
]
|
||||
|
||||
def run_cmd(cmd, cwd=None):
|
||||
"""Run a shell command and return success status."""
|
||||
print(f"\n>>> {cmd}")
|
||||
try:
|
||||
result = subprocess.run(cmd, shell=True, cwd=cwd, capture_output=True, text=True)
|
||||
if result.stdout:
|
||||
print(result.stdout)
|
||||
if result.stderr:
|
||||
print(result.stderr)
|
||||
return result.returncode == 0
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
return False
|
||||
|
||||
def main():
|
||||
base_dir = os.getcwd()
|
||||
print(f"Working directory: {base_dir}")
|
||||
|
||||
# Step 1: Cleanup
|
||||
print("\n" + "="*50)
|
||||
print("STEP 1: Cleaning up files...")
|
||||
print("="*50)
|
||||
for filename in files_to_remove:
|
||||
filepath = os.path.join(base_dir, filename)
|
||||
if os.path.exists(filepath):
|
||||
try:
|
||||
os.remove(filepath)
|
||||
print(f"✓ Removed: {filename}")
|
||||
except Exception as e:
|
||||
print(f"✗ Error removing {filename}: {e}")
|
||||
else:
|
||||
print(f"- Skipped (not found): {filename}")
|
||||
|
||||
# Step 2: Git operations
|
||||
print("\n" + "="*50)
|
||||
print("STEP 2: Git operations...")
|
||||
print("="*50)
|
||||
|
||||
# Add remote if not exists
|
||||
run_cmd("git remote add private https://git.khoavo.myds.me/vndangkhoa/kv-tube 2>/dev/null || true")
|
||||
|
||||
# Stage all changes
|
||||
run_cmd("git add .")
|
||||
|
||||
# Commit
|
||||
run_cmd('git commit -m "Cleanup and documentation update"')
|
||||
|
||||
# Push to origin
|
||||
print("\nPushing to origin...")
|
||||
run_cmd("git push origin main || git push origin master")
|
||||
|
||||
# Push to private
|
||||
print("\nPushing to private server...")
|
||||
run_cmd("git push private main || git push private master")
|
||||
|
||||
# Step 3: Docker instructions
|
||||
print("\n" + "="*50)
|
||||
print("STEP 3: Docker Build & Push")
|
||||
print("="*50)
|
||||
print("\nTo build and push Docker image, run these commands manually:")
|
||||
print(" docker build -t vndangkhoa/kv-tube:latest .")
|
||||
print(" docker push vndangkhoa/kv-tube:latest")
|
||||
|
||||
# Optionally try to run docker commands
|
||||
print("\nAttempting Docker build...")
|
||||
if run_cmd("docker build -t vndangkhoa/kv-tube:latest ."):
|
||||
print("\nAttempting Docker push...")
|
||||
run_cmd("docker push vndangkhoa/kv-tube:latest")
|
||||
|
||||
print("\n" + "="*50)
|
||||
print("DEPLOYMENT COMPLETE!")
|
||||
print("="*50)
|
||||
print("\nYou can delete this file (deploy.py) now.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
else:
|
||||
# When run via exec()
|
||||
main()
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
# deploy_v2.ps1 - Deploy KV-Tube v2.0
|
||||
|
||||
Write-Host "--- KV-Tube v2.0 Deployment ---" -ForegroundColor Cyan
|
||||
|
||||
# 1. Check Git Remote
|
||||
Write-Host "1. Pushing to Git..." -ForegroundColor Yellow
|
||||
# Note: Ensure 'origin' is the correct writable remote, not a mirror.
|
||||
git push -u origin main --tags
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host "Error: Git push failed. Verify that 'origin' is not a read-only mirror." -ForegroundColor Red
|
||||
# Continue anyway to try Docker?
|
||||
}
|
||||
|
||||
# 2. Build Docker Image
|
||||
Write-Host "2. Building Docker Image (linux/amd64)..." -ForegroundColor Yellow
|
||||
# Requires Docker Desktop to be running
|
||||
docker build --platform linux/amd64 -t kv-tube:v2.0 .
|
||||
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Host "Success! Docker image 'kv-tube:v2.0' built." -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "Error: Docker build failed. Is Docker Desktop running?" -ForegroundColor Red
|
||||
}
|
||||
|
||||
Write-Host "Done." -ForegroundColor Cyan
|
||||
pause
|
||||
0
doc/Product Requirements Document (PRD) - KV-Tube
Normal file → Executable file
0
doc/Product Requirements Document (PRD) - KV-Tube
Normal file → Executable file
0
docker-compose.yml
Normal file → Executable file
0
docker-compose.yml
Normal file → Executable file
|
|
@ -1,29 +0,0 @@
|
|||
from PIL import Image, ImageDraw, ImageFont
|
||||
import os
|
||||
|
||||
def create_icon(size, output_path):
|
||||
"""Create a simple icon with the given size"""
|
||||
img = Image.new('RGB', (size, size), color='#ff0000')
|
||||
d = ImageDraw.Draw(img)
|
||||
|
||||
# Add text to the icon
|
||||
try:
|
||||
font_size = size // 3
|
||||
font = ImageFont.truetype("Arial", font_size)
|
||||
d.text((size//2, size//2), "KV", fill="white", anchor="mm", font=font, align="center")
|
||||
except:
|
||||
# Fallback if font loading fails
|
||||
d.rectangle([size//4, size//4, 3*size//4, 3*size//4], fill="white")
|
||||
|
||||
# Save the icon
|
||||
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
||||
img.save(output_path, 'PNG')
|
||||
|
||||
# Generate icons in different sizes
|
||||
icon_sizes = [192, 512]
|
||||
for size in icon_sizes:
|
||||
output_path = f"static/icons/icon-{size}x{size}.png"
|
||||
create_icon(size, output_path)
|
||||
print(f"Created icon: {output_path}")
|
||||
|
||||
print("Icons generated successfully!")
|
||||
57
kv_server.py
Normal file
57
kv_server.py
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import os
|
||||
import sys
|
||||
import site
|
||||
|
||||
# Try to find and activate the virtual environment
|
||||
try:
|
||||
base_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
except NameError:
|
||||
base_dir = os.getcwd()
|
||||
|
||||
venv_dirs = ['env', '.venv']
|
||||
activated = False
|
||||
|
||||
for venv_name in venv_dirs:
|
||||
venv_path = os.path.join(base_dir, venv_name)
|
||||
if os.path.isdir(venv_path):
|
||||
# Determine site-packages path
|
||||
if sys.platform == 'win32':
|
||||
site_packages = os.path.join(venv_path, 'Lib', 'site-packages')
|
||||
else:
|
||||
# Check for python version in lib
|
||||
lib_path = os.path.join(venv_path, 'lib')
|
||||
if os.path.exists(lib_path):
|
||||
for item in os.listdir(lib_path):
|
||||
if item.startswith('python'):
|
||||
site_packages = os.path.join(lib_path, item, 'site-packages')
|
||||
break
|
||||
|
||||
if site_packages and os.path.exists(site_packages):
|
||||
print(f"Adding virtual environment to path: {site_packages}")
|
||||
site.addsitedir(site_packages)
|
||||
sys.path.insert(0, site_packages)
|
||||
activated = True
|
||||
break
|
||||
|
||||
if not activated:
|
||||
print("WARNING: Could not find or activate a virtual environment (env or .venv).")
|
||||
print("Attempting to run anyway (system packages might be used)...")
|
||||
|
||||
# Add current directory to path so 'app' can be imported
|
||||
sys.path.insert(0, base_dir)
|
||||
|
||||
try:
|
||||
print("Importing app factory...")
|
||||
from app import create_app
|
||||
print("Creating app...")
|
||||
app = create_app()
|
||||
print("Starting KV-Tube Server on port 5002...")
|
||||
app.run(debug=True, host="0.0.0.0", port=5002, use_reloader=False)
|
||||
except ImportError as e:
|
||||
print("\nCRITICAL ERROR: Could not import Flask or required dependencies.")
|
||||
print(f"Error details: {e}")
|
||||
print("\nPlease ensure you are running this script with the correct Python environment.")
|
||||
print("If you are stuck in a '>>>' prompt, try typing exit() first, then run:")
|
||||
print(" source env/bin/activate && python kv_server.py")
|
||||
except Exception as e:
|
||||
print(f"\nAn error occurred while starting the server: {e}")
|
||||
1073
proxy_check.m3u8
1073
proxy_check.m3u8
File diff suppressed because it is too large
Load diff
0
requirements.txt
Normal file → Executable file
0
requirements.txt
Normal file → Executable file
File diff suppressed because one or more lines are too long
|
|
@ -1 +0,0 @@
|
|||
{"description":"\ud83c\udfa7 TOP 10 NH\u1ea0C VI\u1ec6T AI COVER HAY NH\u1ea4T 2025 | Ballad \u2013 Metal Rock \u2013 Cover C\u1ef1c K\u1ef3 C\u1ea3m X\u00fac\nTuy\u1ec3n t\u1eadp 10 ca kh\u00fac Vi\u1ec7t \u0111\u01b0\u1ee3c AI cover v\u1edbi nhi\u1ec1u phong c\u00e1ch: Ballad s\u00e2u l\u1eafng, Metal Rock b\u00f9ng n\u1ed5, v\u00e0 nh\u1eefng b\u1ea3n c\u1ef1c k\u1ef3 c\u1ea3m x\u00fac ch\u1ea1m \u0111\u1ebfn tr\u00e1i tim.\n\ud83c\udfb6 Danh s\u00e1ch b\u00e0i h\u00e1t:\n1.Hai M\u00f9a Noel \u2013 \u0110\u00e0i Ph\u01b0\u01a1ng Trang\n2.Gi\u1eadn H\u1eddn \u2013 Ng\u1ecdc S\u01a1n\n3.N\u1ed7i \u0110au Mu\u1ed9n M\u00e0ng \u2013 Ng\u00f4 Th\u1ee5y Mi\u00ean\n4.Tri\u1ec7u \u0110\u00f3a H\u1ed3ng \u2013 Nh\u1ea1c Ngo\u1ea1i L\u1eddi Vi\u1ec7t\n5.B\u00ean Nhau \u0110\u00eam Nay ( Dancing All Night) Nh\u1ea1c Ngo\u1ea1i L\u1eddi Vi\u1ec7t\n6.Xin C\u00f2n G\u1ecdi T\u00ean Nhau \u2013 Tr\u01b0\u1eddng Sa\n7.Ng\u00e0y Ch\u01b0a Gi\u00f4ng B\u00e3o \u2013 Phan M\u1ea1nh Qu\u1ef3nh\n8.V\u00ec Anh Y\u00eau Em \u2013 \u0110\u1ee9c Tr\u00ed\n9.T\u00ecnh Phai \u2013 B\u1ea3o Ch\u1ea5n\n10.M\u01b0a Chi\u1ec1u - Anh B\u1eb1ng\n\ud83d\udc9b H\u00e3y chia s\u1ebb c\u1ea3m x\u00fac c\u1ee7a b\u1ea1n\nB\u1ea1n th\u00edch phi\u00ean b\u1ea3n Ballad, Metal Rock, hay b\u1ea3n cover c\u1ef1c k\u1ef3 c\u1ea3m x\u00fac nh\u1ea5t?\nH\u00e3y \u0111\u1ec3 l\u1ea1i b\u00ecnh lu\u1eadn b\u00ean d\u01b0\u1edbi!\n\ud83d\udccc Like \u2013 Share \u2013 Subscribe\n\u0110\u1eebng qu\u00ean \u1ee7ng h\u1ed9 k\u00eanh \u0111\u1ec3 kh\u00f4ng b\u1ecf l\u1ee1 nh\u1eefng playlist AI Cover Nh\u1ea1c Vi\u1ec7t m\u1edbi nh\u1ea5t!\n#AICover #NhacVietHayNhat #MetalRockCover #CoverCamXuc #Top10Cover","original_url":"https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1765805811/ei/k7o_afmFHazapt8P1dC92Qg/ip/14.224.158.192/id/344b71adfe77a807/itag/96/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/sgoap/clen%3D44663091%3Bdur%3D2759.668%3Bgir%3Dyes%3Bitag%3D140%3Blmt%3D1765336826803662/sgovp/clen%3D331946345%3Bdur%3D2759.599%3Bgir%3Dyes%3Bitag%3D137%3Blmt%3D1765341180328971/rqh/1/hls_chunk_host/rr5---sn-i3belney.googlevideo.com/xpc/EgVo2aDSNQ%3D%3D/cps/80/met/1765784211,/mh/o0/mm/31,29/mn/sn-i3belney,sn-i3b7kn6s/ms/au,rdu/mv/m/mvi/5/pl/23/rms/au,au/initcwndbps/803750/bui/AYUSA3AaRTPVQR9AxZ34LEZZbskDQYky8w0H-64K2-Agba81LDhyJvj0g3xcpDInynrnA_DiQwpxZb1h/spc/wH4Qqx7pNgBE3ay_Sjas2uufs1KmfJ4_fwxH1n9h1fFKv_xcj6dMs5fl9awBNyrsJPrk2CdTAeSdnuESLCE2crs6/vprv/1/ns/aheANYTAhlRZjT8Yd9mq1RcR/playlist_type/CLEAN/dover/11/txp/5535534/mt/1765783743/fvip/4/keepalive/yes/fexp/51355912,51552689,51565115,51565682,51580968/n/Q2cHoLCNB42OyhS/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,sgoap,sgovp,rqh,xpc,bui,spc,vprv,ns,playlist_type/sig/AJfQdSswRQIgNonUW7lMmUnoMzBbnh31XHqEySuhEQwmfh4YL1nBQO4CIQDRd9XQFY___68jBfyHbyQGW5Qir54itGggV81mbCsnLQ%3D%3D/lsparams/hls_chunk_host,cps,met,mh,mm,mn,ms,mv,mvi,pl,rms,initcwndbps/lsig/APaTxxMwRQIhANb6xXunUvkchc6tqz4TmX0KNFDCLzJnudPYIRXxM4BHAiAVhVDt56hNjHqgVHQn_dify-P31t6iW_MsKa_40ZT3Jw%3D%3D/playlist/index.m3u8","related":[],"stream_url":"/video_proxy?url=https%3A%2F%2Fmanifest.googlevideo.com%2Fapi%2Fmanifest%2Fhls_playlist%2Fexpire%2F1765805811%2Fei%2Fk7o_afmFHazapt8P1dC92Qg%2Fip%2F14.224.158.192%2Fid%2F344b71adfe77a807%2Fitag%2F96%2Fsource%2Fyoutube%2Frequiressl%2Fyes%2Fratebypass%2Fyes%2Fpfa%2F1%2Fsgoap%2Fclen%253D44663091%253Bdur%253D2759.668%253Bgir%253Dyes%253Bitag%253D140%253Blmt%253D1765336826803662%2Fsgovp%2Fclen%253D331946345%253Bdur%253D2759.599%253Bgir%253Dyes%253Bitag%253D137%253Blmt%253D1765341180328971%2Frqh%2F1%2Fhls_chunk_host%2Frr5---sn-i3belney.googlevideo.com%2Fxpc%2FEgVo2aDSNQ%253D%253D%2Fcps%2F80%2Fmet%2F1765784211%2C%2Fmh%2Fo0%2Fmm%2F31%2C29%2Fmn%2Fsn-i3belney%2Csn-i3b7kn6s%2Fms%2Fau%2Crdu%2Fmv%2Fm%2Fmvi%2F5%2Fpl%2F23%2Frms%2Fau%2Cau%2Finitcwndbps%2F803750%2Fbui%2FAYUSA3AaRTPVQR9AxZ34LEZZbskDQYky8w0H-64K2-Agba81LDhyJvj0g3xcpDInynrnA_DiQwpxZb1h%2Fspc%2FwH4Qqx7pNgBE3ay_Sjas2uufs1KmfJ4_fwxH1n9h1fFKv_xcj6dMs5fl9awBNyrsJPrk2CdTAeSdnuESLCE2crs6%2Fvprv%2F1%2Fns%2FaheANYTAhlRZjT8Yd9mq1RcR%2Fplaylist_type%2FCLEAN%2Fdover%2F11%2Ftxp%2F5535534%2Fmt%2F1765783743%2Ffvip%2F4%2Fkeepalive%2Fyes%2Ffexp%2F51355912%2C51552689%2C51565115%2C51565682%2C51580968%2Fn%2FQ2cHoLCNB42OyhS%2Fsparams%2Fexpire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cratebypass%2Cpfa%2Csgoap%2Csgovp%2Crqh%2Cxpc%2Cbui%2Cspc%2Cvprv%2Cns%2Cplaylist_type%2Fsig%2FAJfQdSswRQIgNonUW7lMmUnoMzBbnh31XHqEySuhEQwmfh4YL1nBQO4CIQDRd9XQFY___68jBfyHbyQGW5Qir54itGggV81mbCsnLQ%253D%253D%2Flsparams%2Fhls_chunk_host%2Ccps%2Cmet%2Cmh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Crms%2Cinitcwndbps%2Flsig%2FAPaTxxMwRQIhANb6xXunUvkchc6tqz4TmX0KNFDCLzJnudPYIRXxM4BHAiAVhVDt56hNjHqgVHQn_dify-P31t6iW_MsKa_40ZT3Jw%253D%253D%2Fplaylist%2Findex.m3u8","title":"Top 10 B\u1ea3n Cover Nh\u1ea1c Vi\u1ec7t B\u1ea5t H\u1ee7 - Nh\u1eefng B\u1ea3n Nh\u1ea1c Cover \u0110\u1ec9nh Cao","upload_date":"20251209","uploader":"NDT - Music","view_count":36225}
|
||||
357
server.log
357
server.log
|
|
@ -1,357 +0,0 @@
|
|||
Starting KV-Tube Server on port 5002 (Reloader Disabled)
|
||||
* Serving Flask app 'app'
|
||||
* Debug mode: on
|
||||
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
|
||||
* Running on all addresses (0.0.0.0)
|
||||
* Running on http://127.0.0.1:5002
|
||||
* Running on http://192.168.31.71:5002
|
||||
Press CTRL+C to quit
|
||||
127.0.0.1 - - [10/Jan/2026 09:00:45] "GET /api/download/formats?v=dQw4w9WgXcQ HTTP/1.1" 200 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:01:00] "GET /api/download/formats?v=dQw4w9WgXcQ HTTP/1.1" 200 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:02:11] "GET /watch?v=NpojU2lMUXg HTTP/1.1" 200 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:02:11] "GET /static/css/style.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:02:11] "GET /static/css/modules/watch.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:02:11] "GET /static/css/modules/downloads.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:02:11] "GET /static/css/modules/captions.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:02:11] "GET /static/css/modules/chat.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:02:11] "GET /static/css/modules/variables.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:02:11] "GET /static/css/modules/base.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:02:11] "GET /static/css/modules/utils.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:02:11] "GET /static/css/modules/cards.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:02:11] "GET /static/css/modules/grid.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:02:11] "GET /static/css/modules/layout.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:02:11] "GET /static/css/modules/pages.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:02:11] "GET /static/css/modules/components.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:02:12] "GET /static/js/hls.min.js HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:02:12] "GET /.well-known/appspecific/com.chrome.devtools.json HTTP/1.1" 404 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:02:12] "GET /static/js/webai.js HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:02:12] "GET /static/js/download-manager.js HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:02:12] "GET /static/js/main.js HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:02:12] "GET /api/get_stream_info?v=NpojU2lMUXg HTTP/1.1" 200 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:02:12] "POST /api/save_video HTTP/1.1" 200 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:02:12] "GET /static/manifest.json HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:02:12] "GET /static/icons/icon-192x192.png HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:02:13] "GET /static/js/hls.min.js.map HTTP/1.1" 404 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:02:13] "GET /video_proxy?url=https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1768029622/ei/VqlhaePdB5K1kucPz5C6mAM/ip/113.177.123.195/id/369a2353694c5178/itag/96/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/sgoap/clen%253D11322769%253Bdur%253D699.570%253Bgir%253Dyes%253Bitag%253D140%253Blmt%253D1767929768411512/sgovp/clen%253D68411692%253Bdur%253D699.498%253Bgir%253Dyes%253Bitag%253D137%253Blmt%253D1767931176469535/rqh/1/hls_chunk_host/rr5---sn-8qj-nbo66.googlevideo.com/xpc/EgVo2aDSNQ%253D%253D/met/1768008022,/mh/x2/mm/31,26/mn/sn-8qj-nbo66,sn-30a7rner/ms/au,onr/mv/m/mvi/5/pl/21/rms/au,au/initcwndbps/996250/bui/AYUSA3AVaIkoDGL24WGh-p6Nlslt8lCSTE_KmlgDUTo04McVo_YjNIUFPBCUS5WGdphmSZnWAaf34iOm/spc/wH4Qq685_knzkmekRWEyipeLXhuTrkIHahRgpugunHla1cHIhK3LkklFzCvKnYwdwm-WNOH5pDlSYKTTO7hBJxQT/vprv/1/ns/iEBdTokH1ImQgselaPdC7DwR/playlist_type/CLEAN/dover/11/txp/5532534/mt/1768007570/fvip/5/keepalive/yes/fexp/51355912,51552689,51565115,51565681,51580968/n/1VxmYfXx1HRvrL_YwmE/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,sgoap,sgovp,rqh,xpc,bui,spc,vprv,ns,playlist_type/sig/AJfQdSswRAIgMAYm1fDQ9RY7JckvGQKFgqAqFMuIwAKkdvMGwFkb0MsCIC9Tc-NeUUX8qfDu8u9cl86S9mWLmjiPDbDBbRJTVhDh/lsparams/hls_chunk_host,met,mh,mm,mn,ms,mv,mvi,pl,rms,initcwndbps/lsig/APaTxxMwRQIgQHMhxGTKHYhixOY4Mlxdtai8EgUia0Q-LRl_GSYY4RMCIQCUHH0qyG6y05dSll3X6sXukmhCu8oR6GCOlB4zCUlW-Q%253D%253D/playlist/index.m3u8 HTTP/1.1" 200 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:02:15] "GET /api/transcript?v=NpojU2lMUXg HTTP/1.1" 200 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:04:48] "GET /downloads HTTP/1.1" 404 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:04:48] "GET /.well-known/appspecific/com.chrome.devtools.json HTTP/1.1" 404 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:04:48] "GET /favicon.ico HTTP/1.1" 404 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:04:54] "GET /.well-known/appspecific/com.chrome.devtools.json HTTP/1.1" 404 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:04:54] "GET /static/js/hls.min.js.map HTTP/1.1" 404 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:04:55] "POST /api/save_video HTTP/1.1" 200 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:04:55] "GET /static/manifest.json HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:04:55] "GET /static/icons/icon-192x192.png HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:04:56] "GET /video_proxy?url=https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1768029622/ei/VqlhaePdB5K1kucPz5C6mAM/ip/113.177.123.195/id/369a2353694c5178/itag/96/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/sgoap/clen%253D11322769%253Bdur%253D699.570%253Bgir%253Dyes%253Bitag%253D140%253Blmt%253D1767929768411512/sgovp/clen%253D68411692%253Bdur%253D699.498%253Bgir%253Dyes%253Bitag%253D137%253Blmt%253D1767931176469535/rqh/1/hls_chunk_host/rr5---sn-8qj-nbo66.googlevideo.com/xpc/EgVo2aDSNQ%253D%253D/met/1768008022,/mh/x2/mm/31,26/mn/sn-8qj-nbo66,sn-30a7rner/ms/au,onr/mv/m/mvi/5/pl/21/rms/au,au/initcwndbps/996250/bui/AYUSA3AVaIkoDGL24WGh-p6Nlslt8lCSTE_KmlgDUTo04McVo_YjNIUFPBCUS5WGdphmSZnWAaf34iOm/spc/wH4Qq685_knzkmekRWEyipeLXhuTrkIHahRgpugunHla1cHIhK3LkklFzCvKnYwdwm-WNOH5pDlSYKTTO7hBJxQT/vprv/1/ns/iEBdTokH1ImQgselaPdC7DwR/playlist_type/CLEAN/dover/11/txp/5532534/mt/1768007570/fvip/5/keepalive/yes/fexp/51355912,51552689,51565115,51565681,51580968/n/1VxmYfXx1HRvrL_YwmE/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,sgoap,sgovp,rqh,xpc,bui,spc,vprv,ns,playlist_type/sig/AJfQdSswRAIgMAYm1fDQ9RY7JckvGQKFgqAqFMuIwAKkdvMGwFkb0MsCIC9Tc-NeUUX8qfDu8u9cl86S9mWLmjiPDbDBbRJTVhDh/lsparams/hls_chunk_host,met,mh,mm,mn,ms,mv,mvi,pl,rms,initcwndbps/lsig/APaTxxMwRQIgQHMhxGTKHYhixOY4Mlxdtai8EgUia0Q-LRl_GSYY4RMCIQCUHH0qyG6y05dSll3X6sXukmhCu8oR6GCOlB4zCUlW-Q%253D%253D/playlist/index.m3u8 HTTP/1.1" 200 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:04:59] "GET /api/transcript?v=NpojU2lMUXg HTTP/1.1" 200 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:10:38] "GET /watch?v=NpojU2lMUXg HTTP/1.1" 200 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:10:38] "GET /.well-known/appspecific/com.chrome.devtools.json HTTP/1.1" 404 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:10:38] "GET /static/css/modules/watch.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:10:38] "GET /static/css/modules/captions.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:10:38] "GET /static/css/modules/chat.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:10:38] "GET /static/css/modules/downloads.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:10:38] "GET /static/css/style.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:10:38] "GET /static/js/webai.js HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:10:38] "GET /static/css/modules/variables.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:10:39] "GET /static/css/modules/utils.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:10:39] "GET /static/css/modules/base.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:10:39] "GET /static/css/modules/layout.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:10:39] "GET /static/css/modules/pages.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:10:39] "GET /static/css/modules/components.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:10:39] "GET /static/css/modules/cards.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:10:39] "GET /static/css/modules/grid.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:10:39] "GET /static/js/download-manager.js HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:10:39] "GET /static/js/main.js HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:10:39] "GET /static/js/hls.min.js HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:10:39] "GET /api/get_stream_info?v=NpojU2lMUXg HTTP/1.1" 200 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:10:39] "GET /static/js/hls.min.js.map HTTP/1.1" 404 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:10:40] "GET /static/manifest.json HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:10:40] "POST /api/save_video HTTP/1.1" 200 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:10:40] "GET /static/icons/icon-192x192.png HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:10:40] "GET /video_proxy?url=https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1768029622/ei/VqlhaePdB5K1kucPz5C6mAM/ip/113.177.123.195/id/369a2353694c5178/itag/96/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/sgoap/clen%253D11322769%253Bdur%253D699.570%253Bgir%253Dyes%253Bitag%253D140%253Blmt%253D1767929768411512/sgovp/clen%253D68411692%253Bdur%253D699.498%253Bgir%253Dyes%253Bitag%253D137%253Blmt%253D1767931176469535/rqh/1/hls_chunk_host/rr5---sn-8qj-nbo66.googlevideo.com/xpc/EgVo2aDSNQ%253D%253D/met/1768008022,/mh/x2/mm/31,26/mn/sn-8qj-nbo66,sn-30a7rner/ms/au,onr/mv/m/mvi/5/pl/21/rms/au,au/initcwndbps/996250/bui/AYUSA3AVaIkoDGL24WGh-p6Nlslt8lCSTE_KmlgDUTo04McVo_YjNIUFPBCUS5WGdphmSZnWAaf34iOm/spc/wH4Qq685_knzkmekRWEyipeLXhuTrkIHahRgpugunHla1cHIhK3LkklFzCvKnYwdwm-WNOH5pDlSYKTTO7hBJxQT/vprv/1/ns/iEBdTokH1ImQgselaPdC7DwR/playlist_type/CLEAN/dover/11/txp/5532534/mt/1768007570/fvip/5/keepalive/yes/fexp/51355912,51552689,51565115,51565681,51580968/n/1VxmYfXx1HRvrL_YwmE/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,sgoap,sgovp,rqh,xpc,bui,spc,vprv,ns,playlist_type/sig/AJfQdSswRAIgMAYm1fDQ9RY7JckvGQKFgqAqFMuIwAKkdvMGwFkb0MsCIC9Tc-NeUUX8qfDu8u9cl86S9mWLmjiPDbDBbRJTVhDh/lsparams/hls_chunk_host,met,mh,mm,mn,ms,mv,mvi,pl,rms,initcwndbps/lsig/APaTxxMwRQIgQHMhxGTKHYhixOY4Mlxdtai8EgUia0Q-LRl_GSYY4RMCIQCUHH0qyG6y05dSll3X6sXukmhCu8oR6GCOlB4zCUlW-Q%253D%253D/playlist/index.m3u8 HTTP/1.1" 200 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:10:42] "GET /api/transcript?v=NpojU2lMUXg HTTP/1.1" 200 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:10:50] "GET /api/download/formats?v=NpojU2lMUXg HTTP/1.1" 200 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:11:10] "GET /api/download/formats?v=NpojU2lMUXg HTTP/1.1" 200 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:11:11] "GET /video_proxy?url=https://rr5---sn-8qj-nbo66.googlevideo.com/videoplayback?expire%3D1768032642%26ei%3DIrVhaaCwFJzDrfcPwOWe4AQ%26ip%3D113.177.123.195%26id%3Do-AG7RV2adLTppjbqAgIpnTbchoPu9c71T2gR0WTNI0usm%26itag%3D136%26source%3Dyoutube%26requiressl%3Dyes%26xpc%3DEgVo2aDSNQ%253D%253D%26cps%3D100%26met%3D1768011042%252C%26mh%3Dx2%26mm%3D31%252C26%26mn%3Dsn-8qj-nbo66%252Csn-un57snee%26ms%3Dau%252Conr%26mv%3Dm%26mvi%3D5%26pcm2cms%3Dyes%26pl%3D21%26rms%3Dau%252Cau%26initcwndbps%3D1077500%26bui%3DAW-iu_rZKT33GI2NoS7e4QF9cbqGdiurAwdhaXIfvIkhdU70DIKtAl6ApEAnoOXIisCdqi4jjNG9Mx2i%26spc%3Dq5xjPPux1m4S%26vprv%3D1%26svpuc%3D1%26mime%3Dvideo%252Fmp4%26rqh%3D1%26gir%3Dyes%26clen%3D19834603%26dur%3D699.498%26lmt%3D1767931655919464%26mt%3D1768010689%26fvip%3D1%26keepalive%3Dyes%26fexp%3D51552689%252C51565116%252C51565681%252C51580968%26c%3DANDROID%26txp%3D5532534%26sparams%3Dexpire%252Cei%252Cip%252Cid%252Citag%252Csource%252Crequiressl%252Cxpc%252Cbui%252Cspc%252Cvprv%252Csvpuc%252Cmime%252Crqh%252Cgir%252Cclen%252Cdur%252Clmt%26sig%3DAJfQdSswRQIhAPAMAgGTCqBFbzuLNfmCUepVXVmSY-oaBX4TgITsRu74AiB-HkQX22v4Tu1JJBjfAEw-3kXUQffFunAXj5c11Xmq1Q%253D%253D%26lsparams%3Dcps%252Cmet%252Cmh%252Cmm%252Cmn%252Cms%252Cmv%252Cmvi%252Cpcm2cms%252Cpl%252Crms%252Cinitcwndbps%26lsig%3DAPaTxxMwRQIgXiJeXQB0idn38y3C1xl9W-BLX4yNlznCp6BQtuy92RECIQCbNliaxpl1tyZ2jldeUzps7JgIp0VXaXu9MZQFqSo5WQ%253D%253D HTTP/1.1" 200 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:11:16] "GET /downloads HTTP/1.1" 404 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:11:17] "GET /.well-known/appspecific/com.chrome.devtools.json HTTP/1.1" 404 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:12:42] "GET /downloads HTTP/1.1" 404 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:12:42] "GET /.well-known/appspecific/com.chrome.devtools.json HTTP/1.1" 404 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:15:41] "GET /downloads HTTP/1.1" 404 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:15:41] "GET /.well-known/appspecific/com.chrome.devtools.json HTTP/1.1" 404 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:15:47] "GET / HTTP/1.1" 200 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:15:47] "GET /static/css/style.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:15:47] "GET /static/css/modules/base.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:15:47] "GET /static/css/modules/layout.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:15:47] "GET /static/css/modules/variables.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:15:48] "GET /static/css/modules/grid.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:15:48] "GET /static/css/modules/utils.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:15:48] "GET /static/css/modules/components.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:15:48] "GET /static/css/modules/cards.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:15:48] "GET /static/css/modules/pages.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:15:48] "GET /static/js/main.js HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:15:48] "GET /.well-known/appspecific/com.chrome.devtools.json HTTP/1.1" 404 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:15:48] "GET /static/js/download-manager.js HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:15:49] "GET /static/manifest.json HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:15:49] "GET /static/icons/icon-192x192.png HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:16:16] "GET /downloads HTTP/1.1" 404 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:16:17] "GET /.well-known/appspecific/com.chrome.devtools.json HTTP/1.1" 404 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:16:38] "GET /api/trending?category=all&page=1&sort=newest®ion=vietnam&_=1768011348558 HTTP/1.1" 200 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:16:49] "GET /downloads HTTP/1.1" 404 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:17:07] "GET / HTTP/1.1" 200 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:17:10] "GET /downloads/ HTTP/1.1" 404 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:17:19] "GET /settings HTTP/1.1" 200 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:17:19] "GET /my-videos HTTP/1.1" 200 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:18:07] "GET /downloads HTTP/1.1" 404 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:18:10] "GET /static/js/download-manager.js HTTP/1.1" 200 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:18:14] "GET /api/download HTTP/1.1" 400 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:18:29] "GET /downloads HTTP/1.1" 404 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:21:07] "GET /downloads HTTP/1.1" 404 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:23:33] "GET /downloads HTTP/1.1" 404 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:23:33] "GET /.well-known/appspecific/com.chrome.devtools.json HTTP/1.1" 404 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:23:50] "GET /downloads HTTP/1.1" 404 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:23:51] "GET /.well-known/appspecific/com.chrome.devtools.json HTTP/1.1" 404 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:25:22] "GET /downloads HTTP/1.1" 404 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:25:22] "GET /.well-known/appspecific/com.chrome.devtools.json HTTP/1.1" 404 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:25:31] "GET /downloads HTTP/1.1" 404 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:26:33] "GET /downloads HTTP/1.1" 404 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:26:33] "GET /.well-known/appspecific/com.chrome.devtools.json HTTP/1.1" 404 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:26:37] "GET /downloads HTTP/1.1" 404 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:26:38] "GET /downloads HTTP/1.1" 404 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:26:38] "GET /.well-known/appspecific/com.chrome.devtools.json HTTP/1.1" 404 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:26:42] "GET / HTTP/1.1" 200 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:26:42] "GET /.well-known/appspecific/com.chrome.devtools.json HTTP/1.1" 404 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:26:42] "GET /static/css/style.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:26:42] "GET /static/css/modules/layout.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:26:42] "GET /static/css/modules/variables.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:26:42] "GET /static/css/modules/grid.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:26:42] "GET /static/css/modules/utils.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:26:42] "GET /static/css/modules/base.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:26:42] "GET /static/css/modules/components.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:26:42] "GET /static/css/modules/pages.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:26:42] "GET /static/css/modules/cards.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:26:43] "GET /static/js/download-manager.js HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:26:43] "GET /static/js/main.js HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:26:43] "GET /static/manifest.json HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:26:43] "GET /static/icons/icon-192x192.png HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:26:57] "GET /downloads HTTP/1.1" 404 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:27:02] "GET /api/trending?category=all&page=1&sort=newest®ion=vietnam&_=1768012003015 HTTP/1.1" 200 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:29:19] "GET /downloads HTTP/1.1" 404 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:29:34] "GET /downloads HTTP/1.1" 404 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:30:21] "GET /downloads HTTP/1.1" 404 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:30:21] "GET /.well-known/appspecific/com.chrome.devtools.json HTTP/1.1" 404 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:30:22] "GET /.well-known/appspecific/com.chrome.devtools.json HTTP/1.1" 404 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:30:23] "GET /static/manifest.json HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:30:23] "GET /static/icons/icon-192x192.png HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:30:46] "GET /api/trending?category=all&page=1&sort=newest®ion=vietnam&_=1768012222821 HTTP/1.1" 200 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:34:07] "GET /downloads HTTP/1.1" 404 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:34:07] "GET /.well-known/appspecific/com.chrome.devtools.json HTTP/1.1" 404 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:34:08] "GET /.well-known/appspecific/com.chrome.devtools.json HTTP/1.1" 404 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:34:09] "GET /static/manifest.json HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:34:09] "GET /static/icons/icon-192x192.png HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:34:24] "GET /api/trending?category=all&page=1&sort=newest®ion=vietnam&_=1768012448542 HTTP/1.1" 200 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:38:16] "GET /downloads HTTP/1.1" 404 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:38:16] "GET /.well-known/appspecific/com.chrome.devtools.json HTTP/1.1" 404 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:38:17] "GET /downloads HTTP/1.1" 404 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:38:18] "GET /.well-known/appspecific/com.chrome.devtools.json HTTP/1.1" 404 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:38:20] "GET /downloads HTTP/1.1" 404 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:38:20] "GET /.well-known/appspecific/com.chrome.devtools.json HTTP/1.1" 404 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:38:21] "GET /favicon.ico HTTP/1.1" 404 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:38:27] "GET / HTTP/1.1" 200 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:38:27] "GET /static/css/style.css HTTP/1.1" 200 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:38:27] "GET /static/js/main.js HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:38:27] "GET /static/js/download-manager.js HTTP/1.1" 200 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:38:27] "GET /static/css/modules/grid.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:38:27] "GET /static/css/modules/utils.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:38:27] "GET /static/css/modules/variables.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:38:27] "GET /static/css/modules/base.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:38:27] "GET /static/css/modules/layout.css HTTP/1.1" 200 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:38:27] "GET /static/css/modules/cards.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:38:27] "GET /static/css/modules/components.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:38:27] "GET /static/css/modules/pages.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:38:27] "GET /static/manifest.json HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:38:27] "GET /static/icons/icon-192x192.png HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:38:39] "GET /downloads HTTP/1.1" 404 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:38:39] "GET /favicon.ico HTTP/1.1" 404 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:38:43] "GET / HTTP/1.1" 200 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:38:43] "GET /static/css/style.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:38:43] "GET /static/js/download-manager.js HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:38:43] "GET /static/js/main.js HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:38:43] "GET /static/css/modules/base.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:38:43] "GET /static/css/modules/utils.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:38:43] "GET /static/css/modules/variables.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:38:43] "GET /static/css/modules/grid.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:38:43] "GET /static/css/modules/layout.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:38:43] "GET /static/css/modules/cards.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:38:43] "GET /static/css/modules/pages.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:38:43] "GET /static/css/modules/components.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:38:43] "GET /static/manifest.json HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:38:43] "GET /static/icons/icon-192x192.png HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:38:46] "GET /api/trending?category=all&page=1&sort=newest®ion=vietnam&_=1768012707813 HTTP/1.1" 200 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:38:50] "GET /downloads HTTP/1.1" 404 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:39:00] "GET / HTTP/1.1" 200 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:39:00] "GET /static/css/style.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:39:00] "GET /static/js/download-manager.js HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:39:00] "GET /static/js/main.js HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:39:00] "GET /static/css/modules/utils.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:39:00] "GET /static/css/modules/layout.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:39:00] "GET /static/css/modules/base.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:39:00] "GET /static/css/modules/variables.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:39:00] "GET /static/css/modules/grid.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:39:00] "GET /static/css/modules/cards.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:39:00] "GET /static/css/modules/pages.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:39:00] "GET /static/css/modules/components.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:39:00] "GET /static/manifest.json HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:39:00] "GET /static/icons/icon-192x192.png HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:39:01] "GET /api/trending?category=all&page=1&sort=newest®ion=vietnam&_=1768012723889 HTTP/1.1" 200 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:39:17] "GET /api/trending?category=all&page=1&sort=newest®ion=vietnam&_=1768012740673 HTTP/1.1" 200 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:39:18] "GET /results?search_query=test HTTP/1.1" 200 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:39:18] "GET /static/css/style.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:39:18] "GET /static/css/modules/base.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:39:18] "GET /static/js/download-manager.js HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:39:18] "GET /static/css/modules/variables.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:39:18] "GET /static/css/modules/utils.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:39:18] "GET /static/js/main.js HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:39:18] "GET /static/css/modules/layout.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:39:18] "GET /static/css/modules/grid.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:39:18] "GET /static/css/modules/cards.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:39:18] "GET /static/css/modules/pages.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:39:18] "GET /static/css/modules/components.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:39:18] "GET /static/manifest.json HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:39:18] "GET /static/icons/icon-192x192.png HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:39:24] "GET /api/search?q=test HTTP/1.1" 200 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:39:29] "GET /api/search?q=test HTTP/1.1" 200 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:39:38] "GET /watch?v=UEQSkaqrMZA HTTP/1.1" 200 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:39:39] "GET /static/css/style.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:39:39] "GET /static/css/modules/captions.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:39:39] "GET /static/css/modules/chat.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:39:39] "GET /static/css/modules/downloads.css HTTP/1.1" 200 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:39:39] "GET /static/css/modules/watch.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:39:39] "GET /static/css/modules/variables.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:39:39] "GET /static/css/modules/base.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:39:39] "GET /static/css/modules/utils.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:39:39] "GET /static/css/modules/layout.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:39:39] "GET /static/css/modules/grid.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:39:39] "GET /static/js/main.js HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:39:39] "GET /static/css/modules/components.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:39:39] "GET /static/css/modules/cards.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:39:39] "GET /static/css/modules/pages.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:39:39] "GET /static/js/hls.min.js HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:39:39] "GET /static/js/webai.js HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:39:39] "GET /static/js/download-manager.js HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:39:39] "GET /static/manifest.json HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:39:39] "GET /static/icons/icon-192x192.png HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:39:41] "GET /api/transcript?v=UEQSkaqrMZA HTTP/1.1" 200 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:39:46] "GET /api/get_stream_info?v=UEQSkaqrMZA HTTP/1.1" 200 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:39:46] "POST /api/save_video HTTP/1.1" 200 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:39:47] "GET /video_proxy?url=https://manifest.googlevideo.com/api/manifest/hls_playlist/expire/1768034379/ei/67thabq6NceA2roPlr2UiQ0/ip/113.177.123.195/id/50441291aaab3190/itag/301/source/youtube/requiressl/yes/ratebypass/yes/pfa/1/sgoap/clen%253D75459948%253Bdur%253D4662.613%253Bgir%253Dyes%253Bitag%253D140%253Blmt%253D1754688674773642/sgovp/clen%253D3323639554%253Bdur%253D4662.566%253Bgir%253Dyes%253Bitag%253D299%253Blmt%253D1754693397661282/rqh/1/hls_chunk_host/rr7---sn-8qj-nbo66.googlevideo.com/xpc/EgVo2aDSNQ%253D%253D/cps/0/met/1768012779,/mh/sN/mm/31,26/mn/sn-8qj-nbo66,sn-30a7yney/ms/au,onr/mv/m/mvi/7/pcm2cms/yes/pl/21/rms/au,au/initcwndbps/1183750/bui/AW-iu_pJvbNbbUipw4ySlHfYDzFCSgXKlAvJ7E7SOz9eFoY4D1A8FGsmCpwOjT4YouL0bqVTFsb9FYGM/spc/q5xjPOQ0lvwGtPUVGVv6Xe5k3-YngBrTljtP84h-e-QH24HRpdhwG0sbqSoaOGyo18-HasQFvWw2Qe0uqK263vzC/vprv/1/ns/BVTcFUeLEyzKWVmKrjoT9uMR/playlist_type/CLEAN/dover/11/txp/4402534/mt/1768012372/fvip/3/keepalive/yes/fexp/51355912,51552689,51565116,51565682,51580968/n/KVWd8yqRDv0kaRKAeUg/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,sgoap,sgovp,rqh,xpc,bui,spc,vprv,ns,playlist_type/sig/AJfQdSswRQIgU3oqCX-QEkdiSYvKIS48mGRuEEoWaUTqDIYPVkJkrnsCIQD7P40i02s2UUFUxPkq0ZxgVljTVebXsXuGATgtwi7l8Q%253D%253D/lsparams/hls_chunk_host,cps,met,mh,mm,mn,ms,mv,mvi,pcm2cms,pl,rms,initcwndbps/lsig/APaTxxMwRgIhAOAWh6v9m7fE-0qcRZ24dBXGmCxPpAE7QkiH7KgYr_QOAiEA4pSJBEm2scUcDK95Fh3gtJKU_wEldGYAttesSQmixzQ%253D/playlist/index.m3u8 HTTP/1.1" 200 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:39:47] "GET /video_proxy?url=https://rr7---sn-8qj-nbo66.googlevideo.com/videoplayback/id/50441291aaab3190/itag/301/source/youtube/expire/1768034379/ei/67thabq6NceA2roPlr2UiQ0/ip/113.177.123.195/requiressl/yes/ratebypass/yes/pfa/1/sgoap/clen%253D75459948%253Bdur%253D4662.613%253Bgir%253Dyes%253Bitag%253D140%253Blmt%253D1754688674773642/sgovp/clen%253D3323639554%253Bdur%253D4662.566%253Bgir%253Dyes%253Bitag%253D299%253Blmt%253D1754693397661282/rqh/1/hls_chunk_host/rr7---sn-8qj-nbo66.googlevideo.com/xpc/EgVo2aDSNQ%253D%253D/cps/0/met/1768012779,/mh/sN/mm/31,26/mn/sn-8qj-nbo66,sn-30a7yney/ms/au,onr/mv/m/mvi/7/pcm2cms/yes/pl/21/rms/au,au/initcwndbps/1183750/bui/AW-iu_pJvbNbbUipw4ySlHfYDzFCSgXKlAvJ7E7SOz9eFoY4D1A8FGsmCpwOjT4YouL0bqVTFsb9FYGM/spc/q5xjPOQ0lvwGtPUVGVv6Xe5k3-YngBrTljtP84h-e-QH24HRpdhwG0sbqSoaOGyo18-HasQFvWw2Qe0uqK263vzC/vprv/1/ns/BVTcFUeLEyzKWVmKrjoT9uMR/playlist_type/CLEAN/txp/4402534/mt/1768012372/fvip/3/keepalive/yes/fexp/51355912,51552689,51565116,51565682,51580968/n/KVWd8yqRDv0kaRKAeUg/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,sgoap,sgovp,rqh,xpc,bui,spc,vprv,ns,playlist_type/sig/AJfQdSswRQIgU3oqCX-QEkdiSYvKIS48mGRuEEoWaUTqDIYPVkJkrnsCIQD7P40i02s2UUFUxPkq0ZxgVljTVebXsXuGATgtwi7l8Q%253D%253D/lsparams/hls_chunk_host,cps,met,mh,mm,mn,ms,mv,mvi,pcm2cms,pl,rms,initcwndbps/lsig/APaTxxMwRgIhAOAWh6v9m7fE-0qcRZ24dBXGmCxPpAE7QkiH7KgYr_QOAiEA4pSJBEm2scUcDK95Fh3gtJKU_wEldGYAttesSQmixzQ%253D/playlist/index.m3u8/govp/slices%253D0-272048/goap/slices%253D0-168735/begin/0/len/6000/gosq/0/file/seg.ts HTTP/1.1" 200 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:39:48] "GET /video_proxy?url=https://rr7---sn-8qj-nbo66.googlevideo.com/videoplayback/id/50441291aaab3190/itag/301/source/youtube/expire/1768034379/ei/67thabq6NceA2roPlr2UiQ0/ip/113.177.123.195/requiressl/yes/ratebypass/yes/pfa/1/sgoap/clen%253D75459948%253Bdur%253D4662.613%253Bgir%253Dyes%253Bitag%253D140%253Blmt%253D1754688674773642/sgovp/clen%253D3323639554%253Bdur%253D4662.566%253Bgir%253Dyes%253Bitag%253D299%253Blmt%253D1754693397661282/rqh/1/hls_chunk_host/rr7---sn-8qj-nbo66.googlevideo.com/xpc/EgVo2aDSNQ%253D%253D/cps/0/met/1768012779,/mh/sN/mm/31,26/mn/sn-8qj-nbo66,sn-30a7yney/ms/au,onr/mv/m/mvi/7/pcm2cms/yes/pl/21/rms/au,au/initcwndbps/1183750/bui/AW-iu_pJvbNbbUipw4ySlHfYDzFCSgXKlAvJ7E7SOz9eFoY4D1A8FGsmCpwOjT4YouL0bqVTFsb9FYGM/spc/q5xjPOQ0lvwGtPUVGVv6Xe5k3-YngBrTljtP84h-e-QH24HRpdhwG0sbqSoaOGyo18-HasQFvWw2Qe0uqK263vzC/vprv/1/ns/BVTcFUeLEyzKWVmKrjoT9uMR/playlist_type/CLEAN/txp/4402534/mt/1768012372/fvip/3/keepalive/yes/fexp/51355912,51552689,51565116,51565682,51580968/n/KVWd8yqRDv0kaRKAeUg/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,sgoap,sgovp,rqh,xpc,bui,spc,vprv,ns,playlist_type/sig/AJfQdSswRQIgU3oqCX-QEkdiSYvKIS48mGRuEEoWaUTqDIYPVkJkrnsCIQD7P40i02s2UUFUxPkq0ZxgVljTVebXsXuGATgtwi7l8Q%253D%253D/lsparams/hls_chunk_host,cps,met,mh,mm,mn,ms,mv,mvi,pcm2cms,pl,rms,initcwndbps/lsig/APaTxxMwRgIhAOAWh6v9m7fE-0qcRZ24dBXGmCxPpAE7QkiH7KgYr_QOAiEA4pSJBEm2scUcDK95Fh3gtJKU_wEldGYAttesSQmixzQ%253D/playlist/index.m3u8/govp/slices%253D0-741,272049-1840911/goap/slices%253D0-329748/begin/6000/len/6000/gosq/1/file/seg.ts HTTP/1.1" 200 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:39:48] "GET /video_proxy?url=https://rr7---sn-8qj-nbo66.googlevideo.com/videoplayback/id/50441291aaab3190/itag/301/source/youtube/expire/1768034379/ei/67thabq6NceA2roPlr2UiQ0/ip/113.177.123.195/requiressl/yes/ratebypass/yes/pfa/1/sgoap/clen%253D75459948%253Bdur%253D4662.613%253Bgir%253Dyes%253Bitag%253D140%253Blmt%253D1754688674773642/sgovp/clen%253D3323639554%253Bdur%253D4662.566%253Bgir%253Dyes%253Bitag%253D299%253Blmt%253D1754693397661282/rqh/1/hls_chunk_host/rr7---sn-8qj-nbo66.googlevideo.com/xpc/EgVo2aDSNQ%253D%253D/cps/0/met/1768012779,/mh/sN/mm/31,26/mn/sn-8qj-nbo66,sn-30a7yney/ms/au,onr/mv/m/mvi/7/pcm2cms/yes/pl/21/rms/au,au/initcwndbps/1183750/bui/AW-iu_pJvbNbbUipw4ySlHfYDzFCSgXKlAvJ7E7SOz9eFoY4D1A8FGsmCpwOjT4YouL0bqVTFsb9FYGM/spc/q5xjPOQ0lvwGtPUVGVv6Xe5k3-YngBrTljtP84h-e-QH24HRpdhwG0sbqSoaOGyo18-HasQFvWw2Qe0uqK263vzC/vprv/1/ns/BVTcFUeLEyzKWVmKrjoT9uMR/playlist_type/CLEAN/txp/4402534/mt/1768012372/fvip/3/keepalive/yes/fexp/51355912,51552689,51565116,51565682,51580968/n/KVWd8yqRDv0kaRKAeUg/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,sgoap,sgovp,rqh,xpc,bui,spc,vprv,ns,playlist_type/sig/AJfQdSswRQIgU3oqCX-QEkdiSYvKIS48mGRuEEoWaUTqDIYPVkJkrnsCIQD7P40i02s2UUFUxPkq0ZxgVljTVebXsXuGATgtwi7l8Q%253D%253D/lsparams/hls_chunk_host,cps,met,mh,mm,mn,ms,mv,mvi,pcm2cms,pl,rms,initcwndbps/lsig/APaTxxMwRgIhAOAWh6v9m7fE-0qcRZ24dBXGmCxPpAE7QkiH7KgYr_QOAiEA4pSJBEm2scUcDK95Fh3gtJKU_wEldGYAttesSQmixzQ%253D/playlist/index.m3u8/govp/slices%253D0-741,1840912-5981460/goap/slices%253D0-722,168736-329748/begin/12000/len/6000/gosq/2/file/seg.ts HTTP/1.1" 200 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:39:49] "GET /video_proxy?url=https://rr7---sn-8qj-nbo66.googlevideo.com/videoplayback/id/50441291aaab3190/itag/301/source/youtube/expire/1768034379/ei/67thabq6NceA2roPlr2UiQ0/ip/113.177.123.195/requiressl/yes/ratebypass/yes/pfa/1/sgoap/clen%253D75459948%253Bdur%253D4662.613%253Bgir%253Dyes%253Bitag%253D140%253Blmt%253D1754688674773642/sgovp/clen%253D3323639554%253Bdur%253D4662.566%253Bgir%253Dyes%253Bitag%253D299%253Blmt%253D1754693397661282/rqh/1/hls_chunk_host/rr7---sn-8qj-nbo66.googlevideo.com/xpc/EgVo2aDSNQ%253D%253D/cps/0/met/1768012779,/mh/sN/mm/31,26/mn/sn-8qj-nbo66,sn-30a7yney/ms/au,onr/mv/m/mvi/7/pcm2cms/yes/pl/21/rms/au,au/initcwndbps/1183750/bui/AW-iu_pJvbNbbUipw4ySlHfYDzFCSgXKlAvJ7E7SOz9eFoY4D1A8FGsmCpwOjT4YouL0bqVTFsb9FYGM/spc/q5xjPOQ0lvwGtPUVGVv6Xe5k3-YngBrTljtP84h-e-QH24HRpdhwG0sbqSoaOGyo18-HasQFvWw2Qe0uqK263vzC/vprv/1/ns/BVTcFUeLEyzKWVmKrjoT9uMR/playlist_type/CLEAN/txp/4402534/mt/1768012372/fvip/3/keepalive/yes/fexp/51355912,51552689,51565116,51565682,51580968/n/KVWd8yqRDv0kaRKAeUg/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,sgoap,sgovp,rqh,xpc,bui,spc,vprv,ns,playlist_type/sig/AJfQdSswRQIgU3oqCX-QEkdiSYvKIS48mGRuEEoWaUTqDIYPVkJkrnsCIQD7P40i02s2UUFUxPkq0ZxgVljTVebXsXuGATgtwi7l8Q%253D%253D/lsparams/hls_chunk_host,cps,met,mh,mm,mn,ms,mv,mvi,pcm2cms,pl,rms,initcwndbps/lsig/APaTxxMwRgIhAOAWh6v9m7fE-0qcRZ24dBXGmCxPpAE7QkiH7KgYr_QOAiEA4pSJBEm2scUcDK95Fh3gtJKU_wEldGYAttesSQmixzQ%253D/playlist/index.m3u8/govp/slices%253D0-741,5981461-9628931/goap/slices%253D0-722,168736-491227/begin/18000/len/5033/gosq/3/file/seg.ts HTTP/1.1" 200 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:39:49] "GET /video_proxy?url=https://rr7---sn-8qj-nbo66.googlevideo.com/videoplayback/id/50441291aaab3190/itag/301/source/youtube/expire/1768034379/ei/67thabq6NceA2roPlr2UiQ0/ip/113.177.123.195/requiressl/yes/ratebypass/yes/pfa/1/sgoap/clen%253D75459948%253Bdur%253D4662.613%253Bgir%253Dyes%253Bitag%253D140%253Blmt%253D1754688674773642/sgovp/clen%253D3323639554%253Bdur%253D4662.566%253Bgir%253Dyes%253Bitag%253D299%253Blmt%253D1754693397661282/rqh/1/hls_chunk_host/rr7---sn-8qj-nbo66.googlevideo.com/xpc/EgVo2aDSNQ%253D%253D/cps/0/met/1768012779,/mh/sN/mm/31,26/mn/sn-8qj-nbo66,sn-30a7yney/ms/au,onr/mv/m/mvi/7/pcm2cms/yes/pl/21/rms/au,au/initcwndbps/1183750/bui/AW-iu_pJvbNbbUipw4ySlHfYDzFCSgXKlAvJ7E7SOz9eFoY4D1A8FGsmCpwOjT4YouL0bqVTFsb9FYGM/spc/q5xjPOQ0lvwGtPUVGVv6Xe5k3-YngBrTljtP84h-e-QH24HRpdhwG0sbqSoaOGyo18-HasQFvWw2Qe0uqK263vzC/vprv/1/ns/BVTcFUeLEyzKWVmKrjoT9uMR/playlist_type/CLEAN/txp/4402534/mt/1768012372/fvip/3/keepalive/yes/fexp/51355912,51552689,51565116,51565682,51580968/n/KVWd8yqRDv0kaRKAeUg/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,sgoap,sgovp,rqh,xpc,bui,spc,vprv,ns,playlist_type/sig/AJfQdSswRQIgU3oqCX-QEkdiSYvKIS48mGRuEEoWaUTqDIYPVkJkrnsCIQD7P40i02s2UUFUxPkq0ZxgVljTVebXsXuGATgtwi7l8Q%253D%253D/lsparams/hls_chunk_host,cps,met,mh,mm,mn,ms,mv,mvi,pcm2cms,pl,rms,initcwndbps/lsig/APaTxxMwRgIhAOAWh6v9m7fE-0qcRZ24dBXGmCxPpAE7QkiH7KgYr_QOAiEA4pSJBEm2scUcDK95Fh3gtJKU_wEldGYAttesSQmixzQ%253D/playlist/index.m3u8/govp/slices%253D0-741,9628932-13929407/goap/slices%253D0-722,329749-491227/begin/23033/len/6000/gosq/4/file/seg.ts HTTP/1.1" 200 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:39:51] "GET /video_proxy?url=https://rr7---sn-8qj-nbo66.googlevideo.com/videoplayback/id/50441291aaab3190/itag/301/source/youtube/expire/1768034379/ei/67thabq6NceA2roPlr2UiQ0/ip/113.177.123.195/requiressl/yes/ratebypass/yes/pfa/1/sgoap/clen%253D75459948%253Bdur%253D4662.613%253Bgir%253Dyes%253Bitag%253D140%253Blmt%253D1754688674773642/sgovp/clen%253D3323639554%253Bdur%253D4662.566%253Bgir%253Dyes%253Bitag%253D299%253Blmt%253D1754693397661282/rqh/1/hls_chunk_host/rr7---sn-8qj-nbo66.googlevideo.com/xpc/EgVo2aDSNQ%253D%253D/cps/0/met/1768012779,/mh/sN/mm/31,26/mn/sn-8qj-nbo66,sn-30a7yney/ms/au,onr/mv/m/mvi/7/pcm2cms/yes/pl/21/rms/au,au/initcwndbps/1183750/bui/AW-iu_pJvbNbbUipw4ySlHfYDzFCSgXKlAvJ7E7SOz9eFoY4D1A8FGsmCpwOjT4YouL0bqVTFsb9FYGM/spc/q5xjPOQ0lvwGtPUVGVv6Xe5k3-YngBrTljtP84h-e-QH24HRpdhwG0sbqSoaOGyo18-HasQFvWw2Qe0uqK263vzC/vprv/1/ns/BVTcFUeLEyzKWVmKrjoT9uMR/playlist_type/CLEAN/txp/4402534/mt/1768012372/fvip/3/keepalive/yes/fexp/51355912,51552689,51565116,51565682,51580968/n/KVWd8yqRDv0kaRKAeUg/sparams/expire,ei,ip,id,itag,source,requiressl,ratebypass,pfa,sgoap,sgovp,rqh,xpc,bui,spc,vprv,ns,playlist_type/sig/AJfQdSswRQIgU3oqCX-QEkdiSYvKIS48mGRuEEoWaUTqDIYPVkJkrnsCIQD7P40i02s2UUFUxPkq0ZxgVljTVebXsXuGATgtwi7l8Q%253D%253D/lsparams/hls_chunk_host,cps,met,mh,mm,mn,ms,mv,mvi,pcm2cms,pl,rms,initcwndbps/lsig/APaTxxMwRgIhAOAWh6v9m7fE-0qcRZ24dBXGmCxPpAE7QkiH7KgYr_QOAiEA4pSJBEm2scUcDK95Fh3gtJKU_wEldGYAttesSQmixzQ%253D/playlist/index.m3u8/govp/slices%253D0-741,13929408-17559142/goap/slices%253D0-722,329749-652869/begin/29033/len/5067/gosq/5/file/seg.ts HTTP/1.1" 200 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:39:58] "GET /api/download/formats?v=UEQSkaqrMZA HTTP/1.1" 200 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:40:20] "GET /api/download/formats?v=UEQSkaqrMZA HTTP/1.1" 200 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:40:21] "GET /video_proxy?url=https://rr7---sn-8qj-nbo66.googlevideo.com/videoplayback?expire%3D1768034396%26ei%3D_LthaaetMMOB2roPuvXZmQM%26ip%3D113.177.123.195%26id%3Do-ABxYS0862Gbq9ifzigpO1mOBgSapEEQ4cIIbhv96bacG%26itag%3D160%26source%3Dyoutube%26requiressl%3Dyes%26xpc%3DEgVo2aDSNQ%253D%253D%26cps%3D6%26met%3D1768012796%252C%26mh%3DsN%26mm%3D31%252C26%26mn%3Dsn-8qj-nbo66%252Csn-30a7rnek%26ms%3Dau%252Conr%26mv%3Dm%26mvi%3D7%26pl%3D21%26rms%3Dau%252Cau%26initcwndbps%3D1183750%26bui%3DAW-iu_qMqHprItOzLDfHbu1DhNUmGzl4TsG8cY6Z0988HNK_Kh195auVwbMW3JuupYy3CGANLhlrtPYK%26spc%3Dq5xjPKnKG2ba%26vprv%3D1%26svpuc%3D1%26mime%3Dvideo%252Fmp4%26rqh%3D1%26gir%3Dyes%26clen%3D33656933%26dur%3D4662.566%26lmt%3D1754695084571544%26mt%3D1768012372%26fvip%3D4%26keepalive%3Dyes%26fexp%3D51552689%252C51565115%252C51565682%252C51580968%26c%3DANDROID%26txp%3D4402534%26sparams%3Dexpire%252Cei%252Cip%252Cid%252Citag%252Csource%252Crequiressl%252Cxpc%252Cbui%252Cspc%252Cvprv%252Csvpuc%252Cmime%252Crqh%252Cgir%252Cclen%252Cdur%252Clmt%26sig%3DAJfQdSswRAIgCDLLCY0fbC3PU8T64H8vaK5OJesAakmpdCEV3ZRpPd4CICibLOH3ShL3f0Nq-Dus7iGElNxxVrGLfRNisAZFWKNv%26lsparams%3Dcps%252Cmet%252Cmh%252Cmm%252Cmn%252Cms%252Cmv%252Cmvi%252Cpl%252Crms%252Cinitcwndbps%26lsig%3DAPaTxxMwRQIhALM1cHp_J3jVOqHv6FmSmlNFvaFKA5Cbut2O6cE5UBFzAiBePh0gu4DjMbpsVHH_T_mMx19GJKUb7QzOtOj9pDFuJg%253D%253D HTTP/1.1" 200 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:40:46] "GET /downloads HTTP/1.1" 404 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:41:43] "GET /downloads HTTP/1.1" 404 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:41:47] "GET / HTTP/1.1" 200 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:41:47] "GET /static/css/style.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:41:47] "GET /static/css/modules/variables.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:41:47] "GET /static/css/modules/base.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:41:47] "GET /static/css/modules/utils.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:41:47] "GET /static/css/modules/layout.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:41:47] "GET /static/js/download-manager.js HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:41:47] "GET /static/js/main.js HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:41:47] "GET /static/css/modules/grid.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:41:47] "GET /static/css/modules/cards.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:41:47] "GET /static/css/modules/components.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:41:47] "GET /static/css/modules/pages.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:41:47] "GET /static/manifest.json HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:41:47] "GET /static/icons/icon-192x192.png HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:41:51] "GET /downloads HTTP/1.1" 404 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:41:57] "GET / HTTP/1.1" 200 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:41:57] "GET /static/css/style.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:41:57] "GET /static/js/main.js HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:41:57] "GET /static/js/download-manager.js HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:41:57] "GET /static/css/modules/variables.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:41:57] "GET /static/css/modules/utils.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:41:57] "GET /static/css/modules/base.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:41:57] "GET /static/css/modules/layout.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:41:57] "GET /static/css/modules/grid.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:41:57] "GET /static/css/modules/components.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:41:57] "GET /static/css/modules/cards.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:41:57] "GET /static/css/modules/pages.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:41:57] "GET /static/manifest.json HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:41:57] "GET /static/icons/icon-192x192.png HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:42:06] "GET /downloads HTTP/1.1" 404 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:42:07] "GET /api/trending?category=all&page=1&sort=newest®ion=vietnam&_=1768012907402 HTTP/1.1" 200 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:42:15] "GET /api/trending?category=all&page=1&sort=newest®ion=vietnam&_=1768012917313 HTTP/1.1" 200 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:42:16] "GET /downloads HTTP/1.1" 404 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:42:27] "GET /settings HTTP/1.1" 200 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:42:27] "GET /static/css/style.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:42:27] "GET /static/css/modules/variables.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:42:27] "GET /static/css/modules/utils.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:42:27] "GET /static/css/modules/layout.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:42:27] "GET /static/css/modules/base.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:42:27] "GET /static/js/download-manager.js HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:42:27] "GET /static/js/main.js HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:42:27] "GET /static/css/modules/grid.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:42:27] "GET /static/css/modules/cards.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:42:27] "GET /static/css/modules/pages.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:42:27] "GET /static/css/modules/components.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:42:27] "GET /static/manifest.json HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:42:27] "GET /static/icons/icon-192x192.png HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:42:34] "GET /downloads/ HTTP/1.1" 404 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:42:44] "GET /download HTTP/1.1" 404 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:42:50] "GET /my-videos?type=history HTTP/1.1" 200 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:42:50] "GET /static/css/style.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:42:50] "GET /static/js/download-manager.js HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:42:50] "GET /static/js/main.js HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:42:50] "GET /static/css/modules/variables.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:42:50] "GET /static/css/modules/base.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:42:50] "GET /static/css/modules/utils.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:42:50] "GET /static/css/modules/layout.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:42:50] "GET /static/css/modules/grid.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:42:50] "GET /static/css/modules/components.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:42:50] "GET /static/css/modules/cards.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:42:50] "GET /static/css/modules/pages.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:42:50] "GET /static/manifest.json HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:42:50] "GET /static/icons/icon-192x192.png HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:43:01] "GET /downloads HTTP/1.1" 404 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:43:43] "GET /downloads HTTP/1.1" 404 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:43:53] "GET / HTTP/1.1" 200 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:43:53] "GET /static/css/style.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:43:53] "GET /static/js/download-manager.js HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:43:53] "GET /static/js/main.js HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:43:53] "GET /static/css/modules/variables.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:43:53] "GET /static/css/modules/base.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:43:53] "GET /static/css/modules/utils.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:43:53] "GET /static/css/modules/layout.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:43:53] "GET /static/css/modules/grid.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:43:53] "GET /static/css/modules/cards.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:43:53] "GET /static/css/modules/components.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:43:53] "GET /static/css/modules/pages.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:43:53] "GET /static/manifest.json HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:43:53] "GET /static/icons/icon-192x192.png HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:43:57] "GET /downloads HTTP/1.1" 404 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:44:13] "GET / HTTP/1.1" 200 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:44:13] "GET /static/css/style.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:44:13] "GET /static/js/download-manager.js HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:44:13] "GET /static/js/main.js HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:44:13] "GET /static/css/modules/variables.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:44:13] "GET /static/css/modules/utils.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:44:13] "GET /static/css/modules/base.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:44:13] "GET /static/css/modules/grid.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:44:13] "GET /static/css/modules/layout.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:44:13] "GET /static/css/modules/components.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:44:13] "GET /static/css/modules/cards.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:44:13] "GET /static/css/modules/pages.css HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:44:13] "GET /static/manifest.json HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:44:13] "GET /static/icons/icon-192x192.png HTTP/1.1" 304 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:44:16] "GET /api/trending?category=all&page=1&sort=newest®ion=vietnam&_=1768013033355 HTTP/1.1" 200 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:44:18] "GET /downloads HTTP/1.1" 404 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:44:31] "GET /api/trending?category=all&page=1&sort=newest®ion=vietnam&_=1768013053621 HTTP/1.1" 200 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:45:27] "GET /downloads HTTP/1.1" 404 -
|
||||
127.0.0.1 - - [10/Jan/2026 09:47:11] "GET /downloads HTTP/1.1" 404 -
|
||||
35
start.sh
Normal file
35
start.sh
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
#!/bin/bash
|
||||
cd "$(dirname "$0")"
|
||||
echo "=== Diagnostic Start Script ==="
|
||||
|
||||
# Activate env
|
||||
if [ -d "env" ]; then
|
||||
echo "Activating env..."
|
||||
source env/bin/activate
|
||||
else
|
||||
echo "No 'env' directory found!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Python path: $(which python)"
|
||||
echo "Python ls: $(ls -l $(which python))"
|
||||
|
||||
echo "--- Test 1: Simple Print ---"
|
||||
python -c "print('Python is executing commands properly')"
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "Test 1 PASSED"
|
||||
else
|
||||
echo "Test 1 FAILED (Entered REPL?)"
|
||||
fi
|
||||
|
||||
echo "--- Attempting to start with Gunicorn ---"
|
||||
if [ -f "env/bin/gunicorn" ]; then
|
||||
./env/bin/gunicorn -b 0.0.0.0:5002 wsgi:app
|
||||
else
|
||||
echo "Gunicorn not found."
|
||||
fi
|
||||
|
||||
echo "--- Attempting to start with Flask explicitly ---"
|
||||
export FLASK_APP=wsgi.py
|
||||
export FLASK_RUN_PORT=5002
|
||||
./env/bin/flask run --host=0.0.0.0
|
||||
0
static/css/modules/base.css
Normal file → Executable file
0
static/css/modules/base.css
Normal file → Executable file
144
static/css/modules/captions.css
Normal file → Executable file
144
static/css/modules/captions.css
Normal file → Executable file
|
|
@ -1,73 +1,73 @@
|
|||
/**
|
||||
* KV-Tube Closed Captions Styles
|
||||
* Styling for CC overlay and controls
|
||||
*/
|
||||
|
||||
/* CC Overlay Container */
|
||||
.cc-overlay {
|
||||
position: absolute;
|
||||
bottom: 60px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
max-width: 90%;
|
||||
z-index: 100;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.cc-overlay.hidden {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* CC Text */
|
||||
.cc-text {
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
color: #fff;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
font-size: 18px;
|
||||
line-height: 1.4;
|
||||
text-align: center;
|
||||
max-width: 800px;
|
||||
word-wrap: break-word;
|
||||
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
/* CC Button State */
|
||||
.yt-action-btn.cc-active {
|
||||
color: #fff !important;
|
||||
background: #3ea6ff !important;
|
||||
}
|
||||
|
||||
/* CC Button Icon */
|
||||
.cc-btn-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* Loading state */
|
||||
.cc-loading {
|
||||
font-style: italic;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Mobile adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.cc-overlay {
|
||||
bottom: 50px;
|
||||
max-width: 95%;
|
||||
}
|
||||
|
||||
.cc-text {
|
||||
font-size: 14px;
|
||||
padding: 6px 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Large screen */
|
||||
@media (min-width: 1200px) {
|
||||
.cc-text {
|
||||
font-size: 22px;
|
||||
}
|
||||
/**
|
||||
* KV-Tube Closed Captions Styles
|
||||
* Styling for CC overlay and controls
|
||||
*/
|
||||
|
||||
/* CC Overlay Container */
|
||||
.cc-overlay {
|
||||
position: absolute;
|
||||
bottom: 60px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
max-width: 90%;
|
||||
z-index: 100;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.cc-overlay.hidden {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* CC Text */
|
||||
.cc-text {
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
color: #fff;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
font-size: 18px;
|
||||
line-height: 1.4;
|
||||
text-align: center;
|
||||
max-width: 800px;
|
||||
word-wrap: break-word;
|
||||
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
/* CC Button State */
|
||||
.yt-action-btn.cc-active {
|
||||
color: #fff !important;
|
||||
background: #3ea6ff !important;
|
||||
}
|
||||
|
||||
/* CC Button Icon */
|
||||
.cc-btn-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* Loading state */
|
||||
.cc-loading {
|
||||
font-style: italic;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Mobile adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.cc-overlay {
|
||||
bottom: 50px;
|
||||
max-width: 95%;
|
||||
}
|
||||
|
||||
.cc-text {
|
||||
font-size: 14px;
|
||||
padding: 6px 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Large screen */
|
||||
@media (min-width: 1200px) {
|
||||
.cc-text {
|
||||
font-size: 22px;
|
||||
}
|
||||
}
|
||||
0
static/css/modules/cards.css
Normal file → Executable file
0
static/css/modules/cards.css
Normal file → Executable file
556
static/css/modules/chat.css
Normal file → Executable file
556
static/css/modules/chat.css
Normal file → Executable file
|
|
@ -1,246 +1,312 @@
|
|||
/**
|
||||
* KV-Tube AI Chat Styles
|
||||
* Styling for the transcript Q&A chatbot panel
|
||||
*/
|
||||
|
||||
/* Chat Panel Container */
|
||||
.ai-chat-panel {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
width: 380px;
|
||||
max-height: 500px;
|
||||
background: var(--yt-bg-primary, #0f0f0f);
|
||||
border: 1px solid var(--yt-border, #272727);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 9999;
|
||||
overflow: hidden;
|
||||
transform: translateY(110%);
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.ai-chat-panel.visible {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Chat Header */
|
||||
.ai-chat-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.ai-chat-header h4 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ai-chat-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
opacity: 0.8;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.ai-chat-close:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Model Status */
|
||||
.ai-model-status {
|
||||
font-size: 11px;
|
||||
opacity: 0.9;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.ai-model-status.loading {
|
||||
color: #ffd700;
|
||||
}
|
||||
|
||||
.ai-model-status.ready {
|
||||
color: #00ff88;
|
||||
}
|
||||
|
||||
/* Messages Container */
|
||||
.ai-chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
max-height: 300px;
|
||||
}
|
||||
|
||||
/* Message Bubbles */
|
||||
.ai-message {
|
||||
max-width: 85%;
|
||||
padding: 10px 14px;
|
||||
border-radius: 16px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.ai-message.user {
|
||||
align-self: flex-end;
|
||||
background: #3ea6ff;
|
||||
color: white;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
|
||||
.ai-message.assistant {
|
||||
align-self: flex-start;
|
||||
background: var(--yt-bg-secondary, #272727);
|
||||
color: var(--yt-text-primary, #fff);
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
|
||||
.ai-message.system {
|
||||
align-self: center;
|
||||
background: transparent;
|
||||
color: var(--yt-text-secondary, #aaa);
|
||||
font-style: italic;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Typing Indicator */
|
||||
.ai-typing {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
|
||||
.ai-typing span {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: var(--yt-text-secondary, #aaa);
|
||||
border-radius: 50%;
|
||||
animation: typing 1.2s infinite;
|
||||
}
|
||||
|
||||
.ai-typing span:nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.ai-typing span:nth-child(3) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
@keyframes typing {
|
||||
|
||||
0%,
|
||||
60%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
30% {
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Input Area */
|
||||
.ai-chat-input {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border-top: 1px solid var(--yt-border, #272727);
|
||||
background: var(--yt-bg-secondary, #181818);
|
||||
}
|
||||
|
||||
.ai-chat-input input {
|
||||
flex: 1;
|
||||
background: var(--yt-bg-primary, #0f0f0f);
|
||||
border: 1px solid var(--yt-border, #272727);
|
||||
border-radius: 20px;
|
||||
padding: 10px 16px;
|
||||
color: var(--yt-text-primary, #fff);
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.ai-chat-input input:focus {
|
||||
border-color: #3ea6ff;
|
||||
}
|
||||
|
||||
.ai-chat-input input::placeholder {
|
||||
color: var(--yt-text-secondary, #aaa);
|
||||
}
|
||||
|
||||
.ai-chat-send {
|
||||
background: #3ea6ff;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.2s, transform 0.2s;
|
||||
}
|
||||
|
||||
.ai-chat-send:hover {
|
||||
background: #2d8fd9;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.ai-chat-send:disabled {
|
||||
background: #555;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Download Progress */
|
||||
.ai-download-progress {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ai-download-bar {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: var(--yt-bg-secondary, #272727);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.ai-download-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #667eea, #764ba2);
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
|
||||
.ai-download-text {
|
||||
font-size: 12px;
|
||||
color: var(--yt-text-secondary, #aaa);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* Mobile */
|
||||
@media (max-width: 768px) {
|
||||
.ai-chat-panel {
|
||||
width: calc(100% - 20px);
|
||||
left: 10px;
|
||||
right: 10px;
|
||||
bottom: 10px;
|
||||
max-height: 60vh;
|
||||
}
|
||||
/**
|
||||
* KV-Tube AI Chat Styles
|
||||
* Styling for the transcript Q&A chatbot panel
|
||||
*/
|
||||
|
||||
/* Floating AI Bubble Button */
|
||||
.ai-chat-bubble {
|
||||
position: fixed;
|
||||
bottom: 90px;
|
||||
/* Above the back button */
|
||||
right: 20px;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border: none;
|
||||
color: white;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9998;
|
||||
box-shadow: 0 4px 16px rgba(102, 126, 234, 0.4);
|
||||
transition: transform 0.3s, box-shadow 0.3s;
|
||||
animation: bubble-pulse 2s infinite;
|
||||
}
|
||||
|
||||
.ai-chat-bubble:hover {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 6px 24px rgba(102, 126, 234, 0.6);
|
||||
}
|
||||
|
||||
.ai-chat-bubble.active {
|
||||
animation: none;
|
||||
background: linear-gradient(135deg, #764ba2 0%, #667eea 100%);
|
||||
}
|
||||
|
||||
@keyframes bubble-pulse {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 4px 16px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
50% {
|
||||
box-shadow: 0 4px 24px rgba(102, 126, 234, 0.7);
|
||||
}
|
||||
}
|
||||
|
||||
/* Hide bubble on desktop when chat is open */
|
||||
.ai-chat-panel.visible~.ai-chat-bubble,
|
||||
body.ai-chat-open .ai-chat-bubble {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* Chat Panel Container */
|
||||
.ai-chat-panel {
|
||||
position: fixed;
|
||||
bottom: 160px;
|
||||
/* Position above the bubble */
|
||||
right: 20px;
|
||||
width: 380px;
|
||||
max-height: 500px;
|
||||
background: var(--yt-bg-primary, #0f0f0f);
|
||||
border: 1px solid var(--yt-border, #272727);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 9999;
|
||||
overflow: hidden;
|
||||
transform: translateY(20px) scale(0.95);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s;
|
||||
}
|
||||
|
||||
.ai-chat-panel.visible {
|
||||
transform: translateY(0) scale(1);
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Chat Header */
|
||||
.ai-chat-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.ai-chat-header h4 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ai-chat-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
opacity: 0.8;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.ai-chat-close:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Model Status */
|
||||
.ai-model-status {
|
||||
font-size: 11px;
|
||||
opacity: 0.9;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.ai-model-status.loading {
|
||||
color: #ffd700;
|
||||
}
|
||||
|
||||
.ai-model-status.ready {
|
||||
color: #00ff88;
|
||||
}
|
||||
|
||||
/* Messages Container */
|
||||
.ai-chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
max-height: 300px;
|
||||
}
|
||||
|
||||
/* Message Bubbles */
|
||||
.ai-message {
|
||||
max-width: 85%;
|
||||
padding: 10px 14px;
|
||||
border-radius: 16px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.ai-message.user {
|
||||
align-self: flex-end;
|
||||
background: #3ea6ff;
|
||||
color: white;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
|
||||
.ai-message.assistant {
|
||||
align-self: flex-start;
|
||||
background: var(--yt-bg-secondary, #272727);
|
||||
color: var(--yt-text-primary, #fff);
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
|
||||
.ai-message.system {
|
||||
align-self: center;
|
||||
background: transparent;
|
||||
color: var(--yt-text-secondary, #aaa);
|
||||
font-style: italic;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Typing Indicator */
|
||||
.ai-typing {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
|
||||
.ai-typing span {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: var(--yt-text-secondary, #aaa);
|
||||
border-radius: 50%;
|
||||
animation: typing 1.2s infinite;
|
||||
}
|
||||
|
||||
.ai-typing span:nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.ai-typing span:nth-child(3) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
@keyframes typing {
|
||||
|
||||
0%,
|
||||
60%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
30% {
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Input Area */
|
||||
.ai-chat-input {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border-top: 1px solid var(--yt-border, #272727);
|
||||
background: var(--yt-bg-secondary, #181818);
|
||||
}
|
||||
|
||||
.ai-chat-input input {
|
||||
flex: 1;
|
||||
background: var(--yt-bg-primary, #0f0f0f);
|
||||
border: 1px solid var(--yt-border, #272727);
|
||||
border-radius: 20px;
|
||||
padding: 10px 16px;
|
||||
color: var(--yt-text-primary, #fff);
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.ai-chat-input input:focus {
|
||||
border-color: #3ea6ff;
|
||||
}
|
||||
|
||||
.ai-chat-input input::placeholder {
|
||||
color: var(--yt-text-secondary, #aaa);
|
||||
}
|
||||
|
||||
.ai-chat-send {
|
||||
background: #3ea6ff;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.2s, transform 0.2s;
|
||||
}
|
||||
|
||||
.ai-chat-send:hover {
|
||||
background: #2d8fd9;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.ai-chat-send:disabled {
|
||||
background: #555;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Download Progress */
|
||||
.ai-download-progress {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ai-download-bar {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: var(--yt-bg-secondary, #272727);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.ai-download-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #667eea, #764ba2);
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
|
||||
.ai-download-text {
|
||||
font-size: 12px;
|
||||
color: var(--yt-text-secondary, #aaa);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* Mobile */
|
||||
@media (max-width: 768px) {
|
||||
.ai-chat-bubble {
|
||||
bottom: 100px;
|
||||
/* More space above back button */
|
||||
right: 24px;
|
||||
/* Aligned with back button */
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.ai-chat-panel {
|
||||
width: calc(100% - 20px);
|
||||
left: 10px;
|
||||
right: 10px;
|
||||
bottom: 160px;
|
||||
max-height: 50vh;
|
||||
}
|
||||
}
|
||||
6
static/css/modules/components.css
Normal file → Executable file
6
static/css/modules/components.css
Normal file → Executable file
|
|
@ -111,6 +111,12 @@
|
|||
.yt-floating-back {
|
||||
display: flex;
|
||||
/* Show only on mobile */
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
font-size: 18px;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
/* Aligned with AI bubble */
|
||||
}
|
||||
|
||||
.yt-floating-back {
|
||||
|
|
|
|||
1390
static/css/modules/downloads.css
Normal file → Executable file
1390
static/css/modules/downloads.css
Normal file → Executable file
File diff suppressed because it is too large
Load diff
28
static/css/modules/grid.css
Normal file → Executable file
28
static/css/modules/grid.css
Normal file → Executable file
|
|
@ -50,40 +50,30 @@
|
|||
/* Mobile Grid Overrides */
|
||||
@media (max-width: 768px) {
|
||||
|
||||
/* Main Grid */
|
||||
/* Main Grid - Single column for mobile */
|
||||
.yt-video-grid {
|
||||
grid-template-columns: repeat(2, 1fr) !important;
|
||||
gap: 8px !important;
|
||||
padding: 0 4px !important;
|
||||
grid-template-columns: 1fr !important;
|
||||
gap: 16px !important;
|
||||
padding: 0 12px !important;
|
||||
background: var(--yt-bg-primary);
|
||||
gap: 1px !important;
|
||||
/* Minimal gap from V4 override */
|
||||
padding: 0 !important;
|
||||
/* V4 override */
|
||||
}
|
||||
|
||||
/* Section Grid (Horizontal Carousel) */
|
||||
/* Section Grid - Single column vertical scroll */
|
||||
.yt-section-grid {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-template-columns: none;
|
||||
grid-template-rows: 1fr;
|
||||
grid-auto-columns: 85%;
|
||||
gap: 12px;
|
||||
overflow-x: auto;
|
||||
grid-template-columns: 1fr !important;
|
||||
gap: 16px;
|
||||
padding-bottom: 12px;
|
||||
scroll-snap-type: x mandatory;
|
||||
scrollbar-width: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.yt-section-grid::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Adjust video card size for single row */
|
||||
/* Adjust video card size for single column */
|
||||
.yt-section-grid .yt-video-card {
|
||||
width: 100%;
|
||||
scroll-snap-align: start;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
30
static/css/modules/layout.css
Normal file → Executable file
30
static/css/modules/layout.css
Normal file → Executable file
|
|
@ -213,16 +213,30 @@
|
|||
|
||||
/* ===== Responsive Layout Overrides ===== */
|
||||
@media (max-width: 1024px) {
|
||||
|
||||
/* Hide sidebar completely on mobile - it slides in as overlay when opened */
|
||||
.yt-sidebar {
|
||||
transform: translateX(-100%);
|
||||
width: var(--yt-sidebar-width);
|
||||
/* Full width when shown */
|
||||
z-index: 1000;
|
||||
/* Above main content */
|
||||
}
|
||||
|
||||
.yt-sidebar.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
/* Main content takes full width on mobile - no margin for sidebar */
|
||||
.yt-main {
|
||||
margin-left: 0;
|
||||
margin-left: 0 !important;
|
||||
/* Override any sidebar-collapsed state */
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Ignore sidebar-collapsed class on mobile */
|
||||
.yt-main.sidebar-collapsed {
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
|
||||
.yt-header-center {
|
||||
|
|
@ -253,9 +267,19 @@
|
|||
padding: 12px;
|
||||
}
|
||||
|
||||
/* Reduce header padding to match */
|
||||
/* Reduce header padding and make search fill space */
|
||||
.yt-header {
|
||||
padding: 0 12px !important;
|
||||
padding: 0 8px !important;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.yt-header-start {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.yt-header-end {
|
||||
display: none;
|
||||
/* Hide empty header end on mobile */
|
||||
}
|
||||
|
||||
/* Filter bar spacing */
|
||||
|
|
|
|||
0
static/css/modules/pages.css
Normal file → Executable file
0
static/css/modules/pages.css
Normal file → Executable file
0
static/css/modules/utils.css
Normal file → Executable file
0
static/css/modules/utils.css
Normal file → Executable file
0
static/css/modules/variables.css
Normal file → Executable file
0
static/css/modules/variables.css
Normal file → Executable file
1417
static/css/modules/watch.css
Normal file → Executable file
1417
static/css/modules/watch.css
Normal file → Executable file
File diff suppressed because it is too large
Load diff
0
static/css/style.css
Normal file → Executable file
0
static/css/style.css
Normal file → Executable file
0
static/favicon.ico
Normal file → Executable file
0
static/favicon.ico
Normal file → Executable file
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 4.6 KiB |
0
static/icons/icon-192x192.png
Normal file → Executable file
0
static/icons/icon-192x192.png
Normal file → Executable file
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 4.6 KiB |
0
static/icons/icon-512x512.png
Normal file → Executable file
0
static/icons/icon-512x512.png
Normal file → Executable file
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
1234
static/js/download-manager.js
Normal file → Executable file
1234
static/js/download-manager.js
Normal file → Executable file
File diff suppressed because it is too large
Load diff
0
static/js/hls.min.js
vendored
Normal file → Executable file
0
static/js/hls.min.js
vendored
Normal file → Executable file
206
static/js/main.js
Normal file → Executable file
206
static/js/main.js
Normal file → Executable file
|
|
@ -64,6 +64,9 @@ window.initApp = function () {
|
|||
// Init Theme (check if already init)
|
||||
initTheme();
|
||||
|
||||
// Restore sidebar state from localStorage to prevent layout shift
|
||||
initSidebarState();
|
||||
|
||||
// Check for category in URL if we are on home and need to switch
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const category = urlParams.get('category');
|
||||
|
|
@ -335,105 +338,59 @@ async function loadTrending(reset = true) {
|
|||
if (data.mode === 'sections') {
|
||||
if (reset) resultsArea.innerHTML = '';
|
||||
|
||||
// Render Sections
|
||||
// Render Sections
|
||||
const isMobile = window.innerWidth <= 768;
|
||||
// Flatten all section videos into a single unified grid
|
||||
// User requested single vertical scroll instead of per-section carousels
|
||||
let allVideos = [];
|
||||
|
||||
data.data.forEach(section => {
|
||||
const sectionDiv = document.createElement('div');
|
||||
sectionDiv.style.gridColumn = '1 / -1';
|
||||
sectionDiv.style.marginBottom = '24px';
|
||||
const videos = section.videos || [];
|
||||
// Add section info to each video for potential category display
|
||||
videos.forEach(video => {
|
||||
video._sectionId = section.id;
|
||||
video._sectionTitle = section.title;
|
||||
});
|
||||
allVideos = allVideos.concat(videos);
|
||||
});
|
||||
|
||||
// Header
|
||||
// Make title clickable - user request
|
||||
const categoryLink = section.id === 'suggested' || section.id === 'discovery'
|
||||
? '#'
|
||||
: `/?category=${section.id}`;
|
||||
// Render all videos in a unified grid
|
||||
allVideos.forEach(video => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'yt-video-card';
|
||||
|
||||
// If it is suggested or discovery, maybe we don't link or link to something generic?
|
||||
// User asked for "categories name has a hyperlink".
|
||||
// Standard categories link to their pages. Suggested/Discovery link to # (no-op) or trending?
|
||||
// Let's link standard ones. For Suggested/Discovery, we can just not link or link to home.
|
||||
// Actually, if we link to /?category=tech it works.
|
||||
// Use a conditional logic for href.
|
||||
|
||||
const titleHtml = (section.id !== 'suggested' && section.id !== 'discovery')
|
||||
? `<a href="/?category=${section.id}" class="yt-section-title-link" style="text-decoration:none; color:inherit; display:flex; align-items:center; gap:10px;">
|
||||
<i class="fas fa-${section.icon}"></i> ${section.title}
|
||||
<i class="fas fa-chevron-right" style="font-size: 14px; opacity: 0.7;"></i>
|
||||
</a>`
|
||||
: `<span style="display:flex; align-items:center; gap:10px;"><i class="fas fa-${section.icon}"></i> ${section.title}</span>`;
|
||||
|
||||
sectionDiv.innerHTML = `
|
||||
<div class="yt-section-header" style="margin-bottom:12px;">
|
||||
<h2>${titleHtml}</h2>
|
||||
card.innerHTML = `
|
||||
<div class="yt-thumbnail-container">
|
||||
<img class="yt-thumbnail" src="${video.thumbnail}" loading="lazy" onload="this.classList.add('loaded')" alt="${escapeHtml(video.title)}">
|
||||
${video.duration ? `<span class="yt-duration">${video.duration}</span>` : ''}
|
||||
</div>
|
||||
<div class="yt-video-details">
|
||||
<div class="yt-channel-avatar">
|
||||
${video.uploader ? video.uploader.charAt(0).toUpperCase() : 'Y'}
|
||||
</div>
|
||||
<div class="yt-video-meta">
|
||||
<h3 class="yt-video-title">${escapeHtml(video.title)}</h3>
|
||||
<p class="yt-channel-name">${escapeHtml(video.uploader || 'Unknown')}</p>
|
||||
<p class="yt-video-stats">${formatViews(video.view_count)} views</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const videos = section.videos || [];
|
||||
let chunks = [];
|
||||
|
||||
if (isMobile) {
|
||||
// Split into 4 chunks (rows) for independent scrolling
|
||||
// Each chunk gets ~1/4 of videos, or at least some
|
||||
const chunkSize = Math.ceil(videos.length / 4);
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const chunk = videos.slice(i * chunkSize, (i + 1) * chunkSize);
|
||||
if (chunk.length > 0) chunks.push(chunk);
|
||||
}
|
||||
} else {
|
||||
// Desktop: 1 big chunk (grid handles layout)
|
||||
chunks.push(videos);
|
||||
}
|
||||
|
||||
chunks.forEach(chunk => {
|
||||
// Scroll Container
|
||||
const scrollContainer = document.createElement('div');
|
||||
scrollContainer.className = 'yt-section-grid';
|
||||
|
||||
chunk.forEach(video => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'yt-video-card';
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="yt-thumbnail-container">
|
||||
<img class="yt-thumbnail" src="${video.thumbnail}" loading="lazy" onload="this.classList.add('loaded')" alt="${escapeHtml(video.title)}">
|
||||
${video.duration ? `<span class="yt-duration">${video.duration}</span>` : ''}
|
||||
</div>
|
||||
<div class="yt-video-details">
|
||||
<div class="yt-channel-avatar">
|
||||
${video.uploader ? video.uploader.charAt(0).toUpperCase() : 'Y'}
|
||||
</div>
|
||||
<div class="yt-video-meta">
|
||||
<h3 class="yt-video-title">${escapeHtml(video.title)}</h3>
|
||||
<p class="yt-channel-name">${escapeHtml(video.uploader || 'Unknown')}</p>
|
||||
<p class="yt-video-stats">${formatViews(video.view_count)} views</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
card.onclick = () => {
|
||||
const params = new URLSearchParams({
|
||||
v: video.id,
|
||||
title: video.title || '',
|
||||
uploader: video.uploader || '',
|
||||
thumbnail: video.thumbnail || ''
|
||||
});
|
||||
const dest = `/watch?${params.toString()}`;
|
||||
|
||||
if (window.navigationManager) {
|
||||
window.navigationManager.navigateTo(dest);
|
||||
} else {
|
||||
window.location.href = dest;
|
||||
}
|
||||
};
|
||||
scrollContainer.appendChild(card);
|
||||
card.onclick = () => {
|
||||
const params = new URLSearchParams({
|
||||
v: video.id,
|
||||
title: video.title || '',
|
||||
uploader: video.uploader || '',
|
||||
thumbnail: video.thumbnail || ''
|
||||
});
|
||||
const dest = `/watch?${params.toString()}`;
|
||||
|
||||
sectionDiv.appendChild(scrollContainer);
|
||||
});
|
||||
|
||||
resultsArea.appendChild(sectionDiv);
|
||||
if (window.navigationManager) {
|
||||
window.navigationManager.navigateTo(dest);
|
||||
} else {
|
||||
window.location.href = dest;
|
||||
}
|
||||
};
|
||||
resultsArea.appendChild(card);
|
||||
});
|
||||
|
||||
if (window.observeImages) window.observeImages();
|
||||
return;
|
||||
}
|
||||
|
|
@ -444,10 +401,12 @@ async function loadTrending(reset = true) {
|
|||
if (reset) {
|
||||
resultsArea.innerHTML = renderNoContent();
|
||||
}
|
||||
hasMore = false; // Only stop if we get no results at all
|
||||
} else {
|
||||
displayResults(data, !reset);
|
||||
// Assume if we got less than limit (20), we reached the end
|
||||
if (data.length < 20) hasMore = false;
|
||||
// Keep loading unless we got 0 videos
|
||||
// Multi-category API returns variable amounts, so don't limit by 20
|
||||
hasMore = data.length > 0;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load trending:', e);
|
||||
|
|
@ -586,14 +545,20 @@ function escapeHtml(text) {
|
|||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Sidebar toggle (for mobile)
|
||||
// Sidebar toggle (for mobile and desktop)
|
||||
function toggleSidebar() {
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const main = document.getElementById('mainContent');
|
||||
const overlay = document.querySelector('.yt-sidebar-overlay');
|
||||
|
||||
if (window.innerWidth <= 1024) {
|
||||
// Mobile: slide-in sidebar with overlay
|
||||
sidebar.classList.toggle('open');
|
||||
if (overlay) {
|
||||
overlay.classList.toggle('active', sidebar.classList.contains('open'));
|
||||
}
|
||||
} else {
|
||||
// Desktop: collapse/expand sidebar
|
||||
sidebar.classList.toggle('collapsed');
|
||||
main.classList.toggle('sidebar-collapsed');
|
||||
localStorage.setItem('sidebarCollapsed', sidebar.classList.contains('collapsed'));
|
||||
|
|
@ -604,6 +569,7 @@ function toggleSidebar() {
|
|||
document.addEventListener('click', (e) => {
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const menuBtn = document.querySelector('.yt-menu-btn');
|
||||
const overlay = document.querySelector('.yt-sidebar-overlay');
|
||||
|
||||
if (window.innerWidth <= 1024 &&
|
||||
sidebar &&
|
||||
|
|
@ -611,18 +577,66 @@ document.addEventListener('click', (e) => {
|
|||
!sidebar.contains(e.target) &&
|
||||
menuBtn && !menuBtn.contains(e.target)) {
|
||||
sidebar.classList.remove('open');
|
||||
if (overlay) {
|
||||
overlay.classList.remove('active');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize sidebar state from localStorage to prevent layout shift
|
||||
function initSidebarState() {
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const main = document.getElementById('mainContent');
|
||||
const overlay = document.querySelector('.yt-sidebar-overlay');
|
||||
|
||||
if (!sidebar || !main) return;
|
||||
|
||||
// Mobile: always hide sidebar (it will slide in when toggled)
|
||||
if (window.innerWidth <= 1024) {
|
||||
sidebar.classList.remove('open', 'collapsed');
|
||||
main.classList.remove('sidebar-collapsed');
|
||||
if (overlay) overlay.classList.remove('active');
|
||||
return;
|
||||
}
|
||||
|
||||
// Desktop: Check if we're on watch page
|
||||
const isWatchPage = document.querySelector('.yt-watch-layout') !== null;
|
||||
|
||||
// On watch page, always use mini sidebar for more video space
|
||||
if (isWatchPage) {
|
||||
sidebar.classList.add('collapsed');
|
||||
main.classList.add('sidebar-collapsed');
|
||||
return;
|
||||
}
|
||||
|
||||
// On other pages, restore from localStorage for consistent experience
|
||||
const savedState = localStorage.getItem('sidebarCollapsed');
|
||||
if (savedState === 'true') {
|
||||
sidebar.classList.add('collapsed');
|
||||
main.classList.add('sidebar-collapsed');
|
||||
} else {
|
||||
sidebar.classList.remove('collapsed');
|
||||
main.classList.remove('sidebar-collapsed');
|
||||
}
|
||||
}
|
||||
|
||||
// Re-initialize sidebar state on window resize
|
||||
let resizeTimer;
|
||||
window.addEventListener('resize', () => {
|
||||
clearTimeout(resizeTimer);
|
||||
resizeTimer = setTimeout(() => {
|
||||
initSidebarState();
|
||||
}, 150);
|
||||
});
|
||||
|
||||
// --- Theme Logic ---
|
||||
function initTheme() {
|
||||
// Check for saved preference
|
||||
let savedTheme = localStorage.getItem('theme');
|
||||
|
||||
// If no saved preference, use Time of Day (Auto)
|
||||
// If no saved preference, default to dark theme
|
||||
if (!savedTheme) {
|
||||
const hour = new Date().getHours();
|
||||
savedTheme = (hour >= 6 && hour < 18) ? 'light' : 'dark';
|
||||
savedTheme = 'dark';
|
||||
}
|
||||
|
||||
setTheme(savedTheme, false); // Initial set without saving (already saved or computed)
|
||||
|
|
|
|||
408
static/js/navigation-manager.js
Normal file → Executable file
408
static/js/navigation-manager.js
Normal file → Executable file
|
|
@ -1,204 +1,204 @@
|
|||
/**
|
||||
* KV-Tube Navigation Manager
|
||||
* Handles SPA-style navigation to persist state (like downloads) across pages.
|
||||
*/
|
||||
|
||||
class NavigationManager {
|
||||
constructor() {
|
||||
this.mainContentId = 'mainContent';
|
||||
this.pageCache = new Map();
|
||||
this.maxCacheSize = 20;
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
// Handle browser back/forward buttons
|
||||
window.addEventListener('popstate', (e) => {
|
||||
if (e.state && e.state.url) {
|
||||
this.loadPage(e.state.url, false);
|
||||
} else {
|
||||
// Fallback for initial state or external navigation
|
||||
this.loadPage(window.location.href, false);
|
||||
}
|
||||
});
|
||||
|
||||
// Intercept clicks
|
||||
document.addEventListener('click', (e) => {
|
||||
// Find closest anchor tag
|
||||
const link = e.target.closest('a');
|
||||
|
||||
// Check if it's an internal link and not a download/special link
|
||||
if (link &&
|
||||
link.href &&
|
||||
link.href.startsWith(window.location.origin) &&
|
||||
!link.getAttribute('download') &&
|
||||
!link.getAttribute('target') &&
|
||||
!link.classList.contains('no-spa') &&
|
||||
!e.ctrlKey && !e.metaKey && !e.shiftKey // Allow new tab clicks
|
||||
) {
|
||||
e.preventDefault();
|
||||
const url = link.href;
|
||||
this.navigateTo(url);
|
||||
|
||||
// Update active state in sidebar
|
||||
this.updateSidebarActiveState(link);
|
||||
}
|
||||
});
|
||||
|
||||
// Save initial state
|
||||
const currentUrl = window.location.href;
|
||||
if (!this.pageCache.has(currentUrl)) {
|
||||
// We don't have the raw HTML, so we can't fully cache the initial page accurately
|
||||
// without fetching it or serializing current DOM.
|
||||
// For now, we will cache it upon *leaving* securely or just let the first visit be uncached.
|
||||
// Better: Cache the current DOM state as the "initial" state.
|
||||
this.saveCurrentState(currentUrl);
|
||||
}
|
||||
}
|
||||
|
||||
saveCurrentState(url) {
|
||||
const mainContent = document.getElementById(this.mainContentId);
|
||||
if (mainContent) {
|
||||
this.pageCache.set(url, {
|
||||
html: mainContent.innerHTML,
|
||||
title: document.title,
|
||||
scrollY: window.scrollY,
|
||||
className: mainContent.className
|
||||
});
|
||||
|
||||
// Prune cache
|
||||
if (this.pageCache.size > this.maxCacheSize) {
|
||||
const firstKey = this.pageCache.keys().next().value;
|
||||
this.pageCache.delete(firstKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async navigateTo(url) {
|
||||
// Start Progress Bar
|
||||
const bar = document.getElementById('nprogress-bar');
|
||||
if (bar) {
|
||||
bar.style.opacity = '1';
|
||||
bar.style.width = '30%';
|
||||
}
|
||||
|
||||
// Save state of current page before leaving
|
||||
this.saveCurrentState(window.location.href);
|
||||
|
||||
// Update history
|
||||
history.pushState({ url: url }, '', url);
|
||||
await this.loadPage(url);
|
||||
}
|
||||
|
||||
async loadPage(url, pushState = true) {
|
||||
const bar = document.getElementById('nprogress-bar');
|
||||
if (bar) bar.style.width = '60%';
|
||||
|
||||
const mainContent = document.getElementById(this.mainContentId);
|
||||
if (!mainContent) return;
|
||||
|
||||
// Check cache
|
||||
if (this.pageCache.has(url)) {
|
||||
const cached = this.pageCache.get(url);
|
||||
|
||||
// Restore content
|
||||
document.title = cached.title;
|
||||
mainContent.innerHTML = cached.html;
|
||||
mainContent.className = cached.className;
|
||||
|
||||
// Re-execute scripts
|
||||
this.executeScripts(mainContent);
|
||||
|
||||
// Re-initialize App
|
||||
if (typeof window.initApp === 'function') {
|
||||
window.initApp();
|
||||
}
|
||||
|
||||
// Restore scroll
|
||||
window.scrollTo(0, cached.scrollY);
|
||||
return;
|
||||
}
|
||||
|
||||
// Show loading state if needed
|
||||
mainContent.style.opacity = '0.5';
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
const html = await response.text();
|
||||
|
||||
// Parse HTML
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, 'text/html');
|
||||
|
||||
// Extract new content
|
||||
const newContent = doc.getElementById(this.mainContentId);
|
||||
if (!newContent) {
|
||||
// Check if it's a full page not extending layout properly or error
|
||||
console.error('Could not find mainContent in response');
|
||||
window.location.href = url; // Fallback to full reload
|
||||
return;
|
||||
}
|
||||
|
||||
// Update title
|
||||
document.title = doc.title;
|
||||
|
||||
// Replace content
|
||||
mainContent.innerHTML = newContent.innerHTML;
|
||||
mainContent.className = newContent.className; // Maintain classes
|
||||
|
||||
// Execute scripts found in the new content (critical for APP_CONFIG)
|
||||
this.executeScripts(mainContent);
|
||||
|
||||
// Re-initialize App logic
|
||||
if (typeof window.initApp === 'function') {
|
||||
window.initApp();
|
||||
}
|
||||
|
||||
// Scroll to top for new pages
|
||||
window.scrollTo(0, 0);
|
||||
|
||||
// Save to cache (initial state of this page)
|
||||
this.pageCache.set(url, {
|
||||
html: newContent.innerHTML,
|
||||
title: doc.title,
|
||||
scrollY: 0,
|
||||
className: newContent.className
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Navigation error:', error);
|
||||
// Fallback
|
||||
window.location.href = url;
|
||||
} finally {
|
||||
mainContent.style.opacity = '1';
|
||||
}
|
||||
}
|
||||
|
||||
executeScripts(element) {
|
||||
const scripts = element.querySelectorAll('script');
|
||||
scripts.forEach(oldScript => {
|
||||
const newScript = document.createElement('script');
|
||||
Array.from(oldScript.attributes).forEach(attr => newScript.setAttribute(attr.name, attr.value));
|
||||
newScript.textContent = oldScript.textContent;
|
||||
oldScript.parentNode.replaceChild(newScript, oldScript);
|
||||
});
|
||||
}
|
||||
|
||||
updateSidebarActiveState(clickedLink) {
|
||||
// Remove active class from all items
|
||||
document.querySelectorAll('.yt-sidebar-item').forEach(item => item.classList.remove('active'));
|
||||
|
||||
// Add to clicked if it is a sidebar item
|
||||
if (clickedLink.classList.contains('yt-sidebar-item')) {
|
||||
clickedLink.classList.add('active');
|
||||
} else {
|
||||
// Try to find matching sidebar item
|
||||
const path = new URL(clickedLink.href).pathname;
|
||||
const match = document.querySelector(`.yt-sidebar-item[href="${path}"]`);
|
||||
if (match) match.classList.add('active');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize
|
||||
window.navigationManager = new NavigationManager();
|
||||
/**
|
||||
* KV-Tube Navigation Manager
|
||||
* Handles SPA-style navigation to persist state (like downloads) across pages.
|
||||
*/
|
||||
|
||||
class NavigationManager {
|
||||
constructor() {
|
||||
this.mainContentId = 'mainContent';
|
||||
this.pageCache = new Map();
|
||||
this.maxCacheSize = 20;
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
// Handle browser back/forward buttons
|
||||
window.addEventListener('popstate', (e) => {
|
||||
if (e.state && e.state.url) {
|
||||
this.loadPage(e.state.url, false);
|
||||
} else {
|
||||
// Fallback for initial state or external navigation
|
||||
this.loadPage(window.location.href, false);
|
||||
}
|
||||
});
|
||||
|
||||
// Intercept clicks
|
||||
document.addEventListener('click', (e) => {
|
||||
// Find closest anchor tag
|
||||
const link = e.target.closest('a');
|
||||
|
||||
// Check if it's an internal link and not a download/special link
|
||||
if (link &&
|
||||
link.href &&
|
||||
link.href.startsWith(window.location.origin) &&
|
||||
!link.getAttribute('download') &&
|
||||
!link.getAttribute('target') &&
|
||||
!link.classList.contains('no-spa') &&
|
||||
!e.ctrlKey && !e.metaKey && !e.shiftKey // Allow new tab clicks
|
||||
) {
|
||||
e.preventDefault();
|
||||
const url = link.href;
|
||||
this.navigateTo(url);
|
||||
|
||||
// Update active state in sidebar
|
||||
this.updateSidebarActiveState(link);
|
||||
}
|
||||
});
|
||||
|
||||
// Save initial state
|
||||
const currentUrl = window.location.href;
|
||||
if (!this.pageCache.has(currentUrl)) {
|
||||
// We don't have the raw HTML, so we can't fully cache the initial page accurately
|
||||
// without fetching it or serializing current DOM.
|
||||
// For now, we will cache it upon *leaving* securely or just let the first visit be uncached.
|
||||
// Better: Cache the current DOM state as the "initial" state.
|
||||
this.saveCurrentState(currentUrl);
|
||||
}
|
||||
}
|
||||
|
||||
saveCurrentState(url) {
|
||||
const mainContent = document.getElementById(this.mainContentId);
|
||||
if (mainContent) {
|
||||
this.pageCache.set(url, {
|
||||
html: mainContent.innerHTML,
|
||||
title: document.title,
|
||||
scrollY: window.scrollY,
|
||||
className: mainContent.className
|
||||
});
|
||||
|
||||
// Prune cache
|
||||
if (this.pageCache.size > this.maxCacheSize) {
|
||||
const firstKey = this.pageCache.keys().next().value;
|
||||
this.pageCache.delete(firstKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async navigateTo(url) {
|
||||
// Start Progress Bar
|
||||
const bar = document.getElementById('nprogress-bar');
|
||||
if (bar) {
|
||||
bar.style.opacity = '1';
|
||||
bar.style.width = '30%';
|
||||
}
|
||||
|
||||
// Save state of current page before leaving
|
||||
this.saveCurrentState(window.location.href);
|
||||
|
||||
// Update history
|
||||
history.pushState({ url: url }, '', url);
|
||||
await this.loadPage(url);
|
||||
}
|
||||
|
||||
async loadPage(url, pushState = true) {
|
||||
const bar = document.getElementById('nprogress-bar');
|
||||
if (bar) bar.style.width = '60%';
|
||||
|
||||
const mainContent = document.getElementById(this.mainContentId);
|
||||
if (!mainContent) return;
|
||||
|
||||
// Check cache
|
||||
if (this.pageCache.has(url)) {
|
||||
const cached = this.pageCache.get(url);
|
||||
|
||||
// Restore content
|
||||
document.title = cached.title;
|
||||
mainContent.innerHTML = cached.html;
|
||||
mainContent.className = cached.className;
|
||||
|
||||
// Re-execute scripts
|
||||
this.executeScripts(mainContent);
|
||||
|
||||
// Re-initialize App
|
||||
if (typeof window.initApp === 'function') {
|
||||
window.initApp();
|
||||
}
|
||||
|
||||
// Restore scroll
|
||||
window.scrollTo(0, cached.scrollY);
|
||||
return;
|
||||
}
|
||||
|
||||
// Show loading state if needed
|
||||
mainContent.style.opacity = '0.5';
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
const html = await response.text();
|
||||
|
||||
// Parse HTML
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, 'text/html');
|
||||
|
||||
// Extract new content
|
||||
const newContent = doc.getElementById(this.mainContentId);
|
||||
if (!newContent) {
|
||||
// Check if it's a full page not extending layout properly or error
|
||||
console.error('Could not find mainContent in response');
|
||||
window.location.href = url; // Fallback to full reload
|
||||
return;
|
||||
}
|
||||
|
||||
// Update title
|
||||
document.title = doc.title;
|
||||
|
||||
// Replace content
|
||||
mainContent.innerHTML = newContent.innerHTML;
|
||||
mainContent.className = newContent.className; // Maintain classes
|
||||
|
||||
// Execute scripts found in the new content (critical for APP_CONFIG)
|
||||
this.executeScripts(mainContent);
|
||||
|
||||
// Re-initialize App logic
|
||||
if (typeof window.initApp === 'function') {
|
||||
window.initApp();
|
||||
}
|
||||
|
||||
// Scroll to top for new pages
|
||||
window.scrollTo(0, 0);
|
||||
|
||||
// Save to cache (initial state of this page)
|
||||
this.pageCache.set(url, {
|
||||
html: newContent.innerHTML,
|
||||
title: doc.title,
|
||||
scrollY: 0,
|
||||
className: newContent.className
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Navigation error:', error);
|
||||
// Fallback
|
||||
window.location.href = url;
|
||||
} finally {
|
||||
mainContent.style.opacity = '1';
|
||||
}
|
||||
}
|
||||
|
||||
executeScripts(element) {
|
||||
const scripts = element.querySelectorAll('script');
|
||||
scripts.forEach(oldScript => {
|
||||
const newScript = document.createElement('script');
|
||||
Array.from(oldScript.attributes).forEach(attr => newScript.setAttribute(attr.name, attr.value));
|
||||
newScript.textContent = oldScript.textContent;
|
||||
oldScript.parentNode.replaceChild(newScript, oldScript);
|
||||
});
|
||||
}
|
||||
|
||||
updateSidebarActiveState(clickedLink) {
|
||||
// Remove active class from all items
|
||||
document.querySelectorAll('.yt-sidebar-item').forEach(item => item.classList.remove('active'));
|
||||
|
||||
// Add to clicked if it is a sidebar item
|
||||
if (clickedLink.classList.contains('yt-sidebar-item')) {
|
||||
clickedLink.classList.add('active');
|
||||
} else {
|
||||
// Try to find matching sidebar item
|
||||
const path = new URL(clickedLink.href).pathname;
|
||||
const match = document.querySelector(`.yt-sidebar-item[href="${path}"]`);
|
||||
if (match) match.classList.add('active');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize
|
||||
window.navigationManager = new NavigationManager();
|
||||
|
|
|
|||
288
static/js/webai.js
Normal file → Executable file
288
static/js/webai.js
Normal file → Executable file
|
|
@ -1,144 +1,144 @@
|
|||
/**
|
||||
* KV-Tube WebAI Service
|
||||
* Local AI chatbot for transcript Q&A using WebLLM
|
||||
*
|
||||
* Runs entirely in-browser, no server required after model download
|
||||
*/
|
||||
|
||||
// WebLLM CDN import (lazy loaded)
|
||||
var WEBLLM_CDN = 'https://esm.run/@mlc-ai/web-llm';
|
||||
|
||||
// Model options - using verified WebLLM model IDs
|
||||
var AI_MODELS = {
|
||||
small: { id: 'Qwen2-0.5B-Instruct-q4f16_1-MLC', name: 'Qwen2 (0.5B)', size: '350MB' },
|
||||
medium: { id: 'Qwen2-1.5B-Instruct-q4f16_1-MLC', name: 'Qwen2 (1.5B)', size: '1GB' },
|
||||
};
|
||||
|
||||
// Default to small model
|
||||
var DEFAULT_MODEL = AI_MODELS.small;
|
||||
|
||||
if (typeof TranscriptAI === 'undefined') {
|
||||
window.TranscriptAI = class TranscriptAI {
|
||||
constructor() {
|
||||
this.engine = null;
|
||||
this.isLoading = false;
|
||||
this.isReady = false;
|
||||
this.transcript = '';
|
||||
this.onProgressCallback = null;
|
||||
this.onReadyCallback = null;
|
||||
}
|
||||
|
||||
setTranscript(text) {
|
||||
this.transcript = text.slice(0, 8000); // Limit context size
|
||||
}
|
||||
|
||||
setCallbacks({ onProgress, onReady }) {
|
||||
this.onProgressCallback = onProgress;
|
||||
this.onReadyCallback = onReady;
|
||||
}
|
||||
|
||||
async init() {
|
||||
if (this.isReady || this.isLoading) return;
|
||||
|
||||
this.isLoading = true;
|
||||
|
||||
try {
|
||||
// Dynamic import WebLLM
|
||||
const { CreateMLCEngine } = await import(WEBLLM_CDN);
|
||||
|
||||
// Initialize engine with progress callback
|
||||
this.engine = await CreateMLCEngine(DEFAULT_MODEL.id, {
|
||||
initProgressCallback: (report) => {
|
||||
if (this.onProgressCallback) {
|
||||
this.onProgressCallback(report);
|
||||
}
|
||||
console.log('AI Load Progress:', report.text);
|
||||
}
|
||||
});
|
||||
|
||||
this.isReady = true;
|
||||
this.isLoading = false;
|
||||
|
||||
if (this.onReadyCallback) {
|
||||
this.onReadyCallback();
|
||||
}
|
||||
|
||||
console.log('TranscriptAI ready with model:', DEFAULT_MODEL.name);
|
||||
|
||||
} catch (err) {
|
||||
this.isLoading = false;
|
||||
console.error('Failed to load AI model:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async ask(question) {
|
||||
if (!this.isReady) {
|
||||
throw new Error('AI not initialized');
|
||||
}
|
||||
|
||||
const systemPrompt = this.transcript
|
||||
? `You are a helpful AI assistant analyzing a video transcript. Answer the user's question based ONLY on the transcript content below. Be concise and direct. If the answer is not in the transcript, say so.\n\nTRANSCRIPT:\n${this.transcript}`
|
||||
: `You are a helpful AI assistant for KV-Tube, a lightweight YouTube client. You can help the user with general questions, explain features of the app, or chat casually. Be concise and helpful.`;
|
||||
|
||||
try {
|
||||
const response = await this.engine.chat.completions.create({
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: question }
|
||||
],
|
||||
max_tokens: 256,
|
||||
temperature: 0.7,
|
||||
});
|
||||
|
||||
return response.choices[0].message.content;
|
||||
|
||||
} catch (err) {
|
||||
console.error('AI response error:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async *askStreaming(question) {
|
||||
if (!this.isReady) {
|
||||
throw new Error('AI not initialized');
|
||||
}
|
||||
|
||||
const systemPrompt = this.transcript
|
||||
? `You are a helpful AI assistant analyzing a video transcript. Answer the user's question based ONLY on the transcript content below. Be concise and direct. If the answer is not in the transcript, say so.\n\nTRANSCRIPT:\n${this.transcript}`
|
||||
: `You are a helpful AI assistant for KV-Tube, a lightweight YouTube client. You can help the user with general questions, explain features of the app, or chat casually. Be concise and helpful.`;
|
||||
|
||||
const chunks = await this.engine.chat.completions.create({
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: question }
|
||||
],
|
||||
max_tokens: 256,
|
||||
temperature: 0.7,
|
||||
stream: true,
|
||||
});
|
||||
|
||||
for await (const chunk of chunks) {
|
||||
const delta = chunk.choices[0]?.delta?.content;
|
||||
if (delta) {
|
||||
yield delta;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getModelInfo() {
|
||||
return DEFAULT_MODEL;
|
||||
}
|
||||
|
||||
isModelReady() {
|
||||
return this.isReady;
|
||||
}
|
||||
|
||||
isModelLoading() {
|
||||
return this.isLoading;
|
||||
}
|
||||
}
|
||||
|
||||
// Global instance
|
||||
window.transcriptAI = new TranscriptAI();
|
||||
}
|
||||
/**
|
||||
* KV-Tube WebAI Service
|
||||
* Local AI chatbot for transcript Q&A using WebLLM
|
||||
*
|
||||
* Runs entirely in-browser, no server required after model download
|
||||
*/
|
||||
|
||||
// WebLLM CDN import (lazy loaded)
|
||||
var WEBLLM_CDN = 'https://esm.run/@mlc-ai/web-llm';
|
||||
|
||||
// Model options - using verified WebLLM model IDs
|
||||
var AI_MODELS = {
|
||||
small: { id: 'Qwen2-0.5B-Instruct-q4f16_1-MLC', name: 'Qwen2 (0.5B)', size: '350MB' },
|
||||
medium: { id: 'Qwen2-1.5B-Instruct-q4f16_1-MLC', name: 'Qwen2 (1.5B)', size: '1GB' },
|
||||
};
|
||||
|
||||
// Default to small model
|
||||
var DEFAULT_MODEL = AI_MODELS.small;
|
||||
|
||||
if (typeof TranscriptAI === 'undefined') {
|
||||
window.TranscriptAI = class TranscriptAI {
|
||||
constructor() {
|
||||
this.engine = null;
|
||||
this.isLoading = false;
|
||||
this.isReady = false;
|
||||
this.transcript = '';
|
||||
this.onProgressCallback = null;
|
||||
this.onReadyCallback = null;
|
||||
}
|
||||
|
||||
setTranscript(text) {
|
||||
this.transcript = text.slice(0, 8000); // Limit context size
|
||||
}
|
||||
|
||||
setCallbacks({ onProgress, onReady }) {
|
||||
this.onProgressCallback = onProgress;
|
||||
this.onReadyCallback = onReady;
|
||||
}
|
||||
|
||||
async init() {
|
||||
if (this.isReady || this.isLoading) return;
|
||||
|
||||
this.isLoading = true;
|
||||
|
||||
try {
|
||||
// Dynamic import WebLLM
|
||||
const { CreateMLCEngine } = await import(WEBLLM_CDN);
|
||||
|
||||
// Initialize engine with progress callback
|
||||
this.engine = await CreateMLCEngine(DEFAULT_MODEL.id, {
|
||||
initProgressCallback: (report) => {
|
||||
if (this.onProgressCallback) {
|
||||
this.onProgressCallback(report);
|
||||
}
|
||||
console.log('AI Load Progress:', report.text);
|
||||
}
|
||||
});
|
||||
|
||||
this.isReady = true;
|
||||
this.isLoading = false;
|
||||
|
||||
if (this.onReadyCallback) {
|
||||
this.onReadyCallback();
|
||||
}
|
||||
|
||||
console.log('TranscriptAI ready with model:', DEFAULT_MODEL.name);
|
||||
|
||||
} catch (err) {
|
||||
this.isLoading = false;
|
||||
console.error('Failed to load AI model:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async ask(question) {
|
||||
if (!this.isReady) {
|
||||
throw new Error('AI not initialized');
|
||||
}
|
||||
|
||||
const systemPrompt = this.transcript
|
||||
? `You are a helpful AI assistant analyzing a video transcript. Answer the user's question based ONLY on the transcript content below. Be concise and direct. If the answer is not in the transcript, say so.\n\nTRANSCRIPT:\n${this.transcript}`
|
||||
: `You are a helpful AI assistant for KV-Tube, a lightweight YouTube client. You can help the user with general questions, explain features of the app, or chat casually. Be concise and helpful.`;
|
||||
|
||||
try {
|
||||
const response = await this.engine.chat.completions.create({
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: question }
|
||||
],
|
||||
max_tokens: 256,
|
||||
temperature: 0.7,
|
||||
});
|
||||
|
||||
return response.choices[0].message.content;
|
||||
|
||||
} catch (err) {
|
||||
console.error('AI response error:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async *askStreaming(question) {
|
||||
if (!this.isReady) {
|
||||
throw new Error('AI not initialized');
|
||||
}
|
||||
|
||||
const systemPrompt = this.transcript
|
||||
? `You are a helpful AI assistant analyzing a video transcript. Answer the user's question based ONLY on the transcript content below. Be concise and direct. If the answer is not in the transcript, say so.\n\nTRANSCRIPT:\n${this.transcript}`
|
||||
: `You are a helpful AI assistant for KV-Tube, a lightweight YouTube client. You can help the user with general questions, explain features of the app, or chat casually. Be concise and helpful.`;
|
||||
|
||||
const chunks = await this.engine.chat.completions.create({
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: question }
|
||||
],
|
||||
max_tokens: 256,
|
||||
temperature: 0.7,
|
||||
stream: true,
|
||||
});
|
||||
|
||||
for await (const chunk of chunks) {
|
||||
const delta = chunk.choices[0]?.delta?.content;
|
||||
if (delta) {
|
||||
yield delta;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getModelInfo() {
|
||||
return DEFAULT_MODEL;
|
||||
}
|
||||
|
||||
isModelReady() {
|
||||
return this.isReady;
|
||||
}
|
||||
|
||||
isModelLoading() {
|
||||
return this.isLoading;
|
||||
}
|
||||
}
|
||||
|
||||
// Global instance
|
||||
window.transcriptAI = new TranscriptAI();
|
||||
}
|
||||
|
|
|
|||
0
static/manifest.json
Normal file → Executable file
0
static/manifest.json
Normal file → Executable file
0
static/sw.js
Normal file → Executable file
0
static/sw.js
Normal file → Executable file
0
templates/channel.html
Normal file → Executable file
0
templates/channel.html
Normal file → Executable file
408
templates/downloads.html
Normal file → Executable file
408
templates/downloads.html
Normal file → Executable file
|
|
@ -1,205 +1,205 @@
|
|||
{% extends "layout.html" %}
|
||||
|
||||
{% block content %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/modules/downloads.css') }}">
|
||||
|
||||
<div class="downloads-page">
|
||||
<div class="downloads-header">
|
||||
<h1><i class="fas fa-download"></i> Downloads</h1>
|
||||
<button class="downloads-clear-btn" onclick="clearAllDownloads()">
|
||||
<i class="fas fa-trash"></i> Clear All
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="downloadsList" class="downloads-list">
|
||||
<!-- Downloads populated by JS -->
|
||||
</div>
|
||||
|
||||
<div id="downloadsEmpty" class="downloads-empty" style="display: none;">
|
||||
<i class="fas fa-download"></i>
|
||||
<p>No downloads yet</p>
|
||||
<p>Videos you download will appear here</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<script>
|
||||
function renderDownloads() {
|
||||
const list = document.getElementById('downloadsList');
|
||||
const empty = document.getElementById('downloadsEmpty');
|
||||
|
||||
if (!list || !empty) return;
|
||||
|
||||
// Safety check for download manager
|
||||
if (!window.downloadManager) {
|
||||
console.log('Download manager not ready, retrying...');
|
||||
setTimeout(renderDownloads, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
const activeDownloads = window.downloadManager.getActiveDownloads();
|
||||
const library = window.downloadManager.getLibrary();
|
||||
|
||||
if (library.length === 0 && activeDownloads.length === 0) {
|
||||
list.style.display = 'none';
|
||||
empty.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
list.style.display = 'flex';
|
||||
empty.style.display = 'none';
|
||||
|
||||
// Render Active Downloads
|
||||
const activeHtml = activeDownloads.map(item => {
|
||||
const specs = item.specs ?
|
||||
(item.type === 'video' ?
|
||||
`${item.specs.resolution || ''} ${item.specs.vcodec ? '• ' + item.specs.vcodec : ''}` :
|
||||
`${item.specs.acodec || ''} • ${item.specs.bitrate ? item.specs.bitrate + 'kbps' : ''}`
|
||||
).trim() : '';
|
||||
|
||||
const isPaused = item.status === 'paused';
|
||||
|
||||
return `
|
||||
<div class="download-item active ${isPaused ? 'paused' : ''}" data-id="${item.id}">
|
||||
<img src="${item.thumbnail || 'https://i.ytimg.com/vi/' + item.videoId + '/mqdefault.jpg'}"
|
||||
class="download-item-thumb">
|
||||
<div class="download-item-info">
|
||||
<div class="download-item-title">${escapeHtml(item.title)}</div>
|
||||
<div class="download-item-meta">
|
||||
<span class="status-text">
|
||||
${isPaused ? '<i class="fas fa-pause-circle"></i> Paused • ' : ''}
|
||||
${item.speedDisplay ? `<i class="fas fa-bolt"></i> ${item.speedDisplay} • ` : ''}
|
||||
${item.eta ? `<i class="fas fa-clock"></i> ${item.eta} • ` : ''}
|
||||
${isPaused ? 'Resuming...' : 'Downloading...'} ${item.progress}%
|
||||
</span>
|
||||
</div>
|
||||
${specs ? `<div class="download-item-specs"><small>${specs}</small></div>` : ''}
|
||||
<div class="download-progress-container">
|
||||
<div class="download-progress-bar ${isPaused ? 'paused' : ''}" style="width: ${item.progress}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="download-item-actions">
|
||||
<button class="download-item-pause" onclick="togglePause('${item.id}')" title="${isPaused ? 'Resume' : 'Pause'}">
|
||||
<i class="fas ${isPaused ? 'fa-play' : 'fa-pause'}"></i>
|
||||
</button>
|
||||
<button class="download-item-remove" onclick="cancelDownload('${item.id}')" title="Cancel">
|
||||
<i class="fas fa-stop"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`}).join('');
|
||||
|
||||
// Render History - with playback support
|
||||
const historyHtml = library.map(item => {
|
||||
const specs = item.specs ?
|
||||
(item.type === 'video' ?
|
||||
`${item.specs.resolution || ''} ${item.specs.vcodec ? '• ' + item.specs.vcodec : ''}` :
|
||||
`${item.specs.acodec || ''} • ${item.specs.bitrate ? item.specs.bitrate + 'kbps' : ''}`
|
||||
).trim() : '';
|
||||
|
||||
return `
|
||||
<div class="download-item playable" data-id="${item.id}" data-video-id="${item.videoId}" onclick="playDownload('${item.videoId}', event)">
|
||||
<div class="download-item-thumb-wrapper">
|
||||
<img src="${item.thumbnail || 'https://i.ytimg.com/vi/' + item.videoId + '/mqdefault.jpg'}"
|
||||
class="download-item-thumb"
|
||||
onerror="this.src='https://via.placeholder.com/160x90?text=No+Thumbnail'">
|
||||
<div class="download-thumb-overlay">
|
||||
<i class="fas fa-play"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="download-item-info">
|
||||
<div class="download-item-title">${escapeHtml(item.title)}</div>
|
||||
<div class="download-item-meta">
|
||||
${item.quality} · ${item.type} · ${formatDate(item.downloadedAt)}
|
||||
${specs ? `<span class="meta-specs">• ${specs}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="download-item-actions">
|
||||
<button class="download-item-play" onclick="playDownload('${item.videoId}', event); event.stopPropagation();" title="Play">
|
||||
<i class="fas fa-play"></i>
|
||||
</button>
|
||||
<button class="download-item-redownload" onclick="reDownload('${item.videoId}', event); event.stopPropagation();" title="Download Again">
|
||||
<i class="fas fa-download"></i>
|
||||
</button>
|
||||
<button class="download-item-remove" onclick="removeDownload('${item.id}'); event.stopPropagation();" title="Remove">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`}).join('');
|
||||
|
||||
|
||||
list.innerHTML = activeHtml + historyHtml;
|
||||
}
|
||||
|
||||
function cancelDownload(id) {
|
||||
window.downloadManager.cancelDownload(id);
|
||||
renderDownloads();
|
||||
}
|
||||
|
||||
function removeDownload(id) {
|
||||
window.downloadManager.removeFromLibrary(id);
|
||||
renderDownloads();
|
||||
}
|
||||
|
||||
function togglePause(id) {
|
||||
const downloads = window.downloadManager.activeDownloads;
|
||||
const state = downloads.get(id);
|
||||
if (!state) return;
|
||||
|
||||
if (state.item.status === 'paused') {
|
||||
window.downloadManager.resumeDownload(id);
|
||||
} else {
|
||||
window.downloadManager.pauseDownload(id);
|
||||
}
|
||||
// renderDownloads will be called by event listener
|
||||
}
|
||||
|
||||
function clearAllDownloads() {
|
||||
if (confirm('Remove all downloads from history?')) {
|
||||
window.downloadManager.clearLibrary();
|
||||
renderDownloads();
|
||||
}
|
||||
}
|
||||
|
||||
function playDownload(videoId, event) {
|
||||
if (event) event.preventDefault();
|
||||
// Navigate to watch page for this video
|
||||
window.location.href = `/watch?v=${videoId}`;
|
||||
}
|
||||
|
||||
function reDownload(videoId, event) {
|
||||
if (event) event.preventDefault();
|
||||
// Open download modal for this video
|
||||
if (typeof showDownloadModal === 'function') {
|
||||
showDownloadModal(videoId);
|
||||
} else {
|
||||
// Fallback: navigate to watch page
|
||||
window.location.href = `/watch?v=${videoId}`;
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
// Render on load with slight delay for download manager
|
||||
document.addEventListener('DOMContentLoaded', () => setTimeout(renderDownloads, 200));
|
||||
|
||||
// Listen for real-time updates
|
||||
// Listen for real-time updates - Prevent duplicates
|
||||
if (window._kvDownloadListener) {
|
||||
window.removeEventListener('download-updated', window._kvDownloadListener);
|
||||
}
|
||||
window._kvDownloadListener = renderDownloads;
|
||||
window.addEventListener('download-updated', renderDownloads);
|
||||
</script>
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block content %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/modules/downloads.css') }}">
|
||||
|
||||
<div class="downloads-page">
|
||||
<div class="downloads-header">
|
||||
<h1><i class="fas fa-download"></i> Downloads</h1>
|
||||
<button class="downloads-clear-btn" onclick="clearAllDownloads()">
|
||||
<i class="fas fa-trash"></i> Clear All
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="downloadsList" class="downloads-list">
|
||||
<!-- Downloads populated by JS -->
|
||||
</div>
|
||||
|
||||
<div id="downloadsEmpty" class="downloads-empty" style="display: none;">
|
||||
<i class="fas fa-download"></i>
|
||||
<p>No downloads yet</p>
|
||||
<p>Videos you download will appear here</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<script>
|
||||
function renderDownloads() {
|
||||
const list = document.getElementById('downloadsList');
|
||||
const empty = document.getElementById('downloadsEmpty');
|
||||
|
||||
if (!list || !empty) return;
|
||||
|
||||
// Safety check for download manager
|
||||
if (!window.downloadManager) {
|
||||
console.log('Download manager not ready, retrying...');
|
||||
setTimeout(renderDownloads, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
const activeDownloads = window.downloadManager.getActiveDownloads();
|
||||
const library = window.downloadManager.getLibrary();
|
||||
|
||||
if (library.length === 0 && activeDownloads.length === 0) {
|
||||
list.style.display = 'none';
|
||||
empty.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
list.style.display = 'flex';
|
||||
empty.style.display = 'none';
|
||||
|
||||
// Render Active Downloads
|
||||
const activeHtml = activeDownloads.map(item => {
|
||||
const specs = item.specs ?
|
||||
(item.type === 'video' ?
|
||||
`${item.specs.resolution || ''} ${item.specs.vcodec ? '• ' + item.specs.vcodec : ''}` :
|
||||
`${item.specs.acodec || ''} • ${item.specs.bitrate ? item.specs.bitrate + 'kbps' : ''}`
|
||||
).trim() : '';
|
||||
|
||||
const isPaused = item.status === 'paused';
|
||||
|
||||
return `
|
||||
<div class="download-item active ${isPaused ? 'paused' : ''}" data-id="${item.id}">
|
||||
<img src="${item.thumbnail || 'https://i.ytimg.com/vi/' + item.videoId + '/mqdefault.jpg'}"
|
||||
class="download-item-thumb">
|
||||
<div class="download-item-info">
|
||||
<div class="download-item-title">${escapeHtml(item.title)}</div>
|
||||
<div class="download-item-meta">
|
||||
<span class="status-text">
|
||||
${isPaused ? '<i class="fas fa-pause-circle"></i> Paused • ' : ''}
|
||||
${item.speedDisplay ? `<i class="fas fa-bolt"></i> ${item.speedDisplay} • ` : ''}
|
||||
${item.eta ? `<i class="fas fa-clock"></i> ${item.eta} • ` : ''}
|
||||
${isPaused ? 'Resuming...' : 'Downloading...'} ${item.progress}%
|
||||
</span>
|
||||
</div>
|
||||
${specs ? `<div class="download-item-specs"><small>${specs}</small></div>` : ''}
|
||||
<div class="download-progress-container">
|
||||
<div class="download-progress-bar ${isPaused ? 'paused' : ''}" style="width: ${item.progress}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="download-item-actions">
|
||||
<button class="download-item-pause" onclick="togglePause('${item.id}')" title="${isPaused ? 'Resume' : 'Pause'}">
|
||||
<i class="fas ${isPaused ? 'fa-play' : 'fa-pause'}"></i>
|
||||
</button>
|
||||
<button class="download-item-remove" onclick="cancelDownload('${item.id}')" title="Cancel">
|
||||
<i class="fas fa-stop"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`}).join('');
|
||||
|
||||
// Render History - with playback support
|
||||
const historyHtml = library.map(item => {
|
||||
const specs = item.specs ?
|
||||
(item.type === 'video' ?
|
||||
`${item.specs.resolution || ''} ${item.specs.vcodec ? '• ' + item.specs.vcodec : ''}` :
|
||||
`${item.specs.acodec || ''} • ${item.specs.bitrate ? item.specs.bitrate + 'kbps' : ''}`
|
||||
).trim() : '';
|
||||
|
||||
return `
|
||||
<div class="download-item playable" data-id="${item.id}" data-video-id="${item.videoId}" onclick="playDownload('${item.videoId}', event)">
|
||||
<div class="download-item-thumb-wrapper">
|
||||
<img src="${item.thumbnail || 'https://i.ytimg.com/vi/' + item.videoId + '/mqdefault.jpg'}"
|
||||
class="download-item-thumb"
|
||||
onerror="this.src='https://via.placeholder.com/160x90?text=No+Thumbnail'">
|
||||
<div class="download-thumb-overlay">
|
||||
<i class="fas fa-play"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="download-item-info">
|
||||
<div class="download-item-title">${escapeHtml(item.title)}</div>
|
||||
<div class="download-item-meta">
|
||||
${item.quality} · ${item.type} · ${formatDate(item.downloadedAt)}
|
||||
${specs ? `<span class="meta-specs">• ${specs}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="download-item-actions">
|
||||
<button class="download-item-play" onclick="playDownload('${item.videoId}', event); event.stopPropagation();" title="Play">
|
||||
<i class="fas fa-play"></i>
|
||||
</button>
|
||||
<button class="download-item-redownload" onclick="reDownload('${item.videoId}', event); event.stopPropagation();" title="Download Again">
|
||||
<i class="fas fa-download"></i>
|
||||
</button>
|
||||
<button class="download-item-remove" onclick="removeDownload('${item.id}'); event.stopPropagation();" title="Remove">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`}).join('');
|
||||
|
||||
|
||||
list.innerHTML = activeHtml + historyHtml;
|
||||
}
|
||||
|
||||
function cancelDownload(id) {
|
||||
window.downloadManager.cancelDownload(id);
|
||||
renderDownloads();
|
||||
}
|
||||
|
||||
function removeDownload(id) {
|
||||
window.downloadManager.removeFromLibrary(id);
|
||||
renderDownloads();
|
||||
}
|
||||
|
||||
function togglePause(id) {
|
||||
const downloads = window.downloadManager.activeDownloads;
|
||||
const state = downloads.get(id);
|
||||
if (!state) return;
|
||||
|
||||
if (state.item.status === 'paused') {
|
||||
window.downloadManager.resumeDownload(id);
|
||||
} else {
|
||||
window.downloadManager.pauseDownload(id);
|
||||
}
|
||||
// renderDownloads will be called by event listener
|
||||
}
|
||||
|
||||
function clearAllDownloads() {
|
||||
if (confirm('Remove all downloads from history?')) {
|
||||
window.downloadManager.clearLibrary();
|
||||
renderDownloads();
|
||||
}
|
||||
}
|
||||
|
||||
function playDownload(videoId, event) {
|
||||
if (event) event.preventDefault();
|
||||
// Navigate to watch page for this video
|
||||
window.location.href = `/watch?v=${videoId}`;
|
||||
}
|
||||
|
||||
function reDownload(videoId, event) {
|
||||
if (event) event.preventDefault();
|
||||
// Open download modal for this video
|
||||
if (typeof showDownloadModal === 'function') {
|
||||
showDownloadModal(videoId);
|
||||
} else {
|
||||
// Fallback: navigate to watch page
|
||||
window.location.href = `/watch?v=${videoId}`;
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
// Render on load with slight delay for download manager
|
||||
document.addEventListener('DOMContentLoaded', () => setTimeout(renderDownloads, 200));
|
||||
|
||||
// Listen for real-time updates
|
||||
// Listen for real-time updates - Prevent duplicates
|
||||
if (window._kvDownloadListener) {
|
||||
window.removeEventListener('download-updated', window._kvDownloadListener);
|
||||
}
|
||||
window._kvDownloadListener = renderDownloads;
|
||||
window.addEventListener('download-updated', renderDownloads);
|
||||
</script>
|
||||
{% endblock %}
|
||||
0
templates/index.html
Normal file → Executable file
0
templates/index.html
Normal file → Executable file
26
templates/layout.html
Normal file → Executable file
26
templates/layout.html
Normal file → Executable file
|
|
@ -131,8 +131,8 @@
|
|||
(function () {
|
||||
let savedTheme = localStorage.getItem('theme');
|
||||
if (!savedTheme) {
|
||||
const hour = new Date().getHours();
|
||||
savedTheme = (hour >= 6 && hour < 18) ? 'light' : 'dark';
|
||||
// Default to dark theme for better experience
|
||||
savedTheme = 'dark';
|
||||
}
|
||||
document.documentElement.setAttribute('data-theme', savedTheme);
|
||||
})();
|
||||
|
|
@ -166,13 +166,7 @@
|
|||
</div>
|
||||
|
||||
<div class="yt-header-end">
|
||||
<button class="yt-icon-btn" onclick="toggleAIChat()" aria-label="AI Assistant">
|
||||
<i class="fas fa-robot"></i>
|
||||
</button>
|
||||
<!-- Mobile Search Icon Removed - Search will be visible -->
|
||||
<!-- <button class="yt-icon-btn yt-mobile-search" onclick="toggleMobileSearch()" aria-label="Search">
|
||||
<i class="fas fa-search"></i>
|
||||
</button> -->
|
||||
<!-- AI Assistant moved to floating bubble -->
|
||||
<!-- User Avatar Removed -->
|
||||
</div>
|
||||
</header>
|
||||
|
|
@ -269,6 +263,11 @@
|
|||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<!-- Floating AI Chat Bubble -->
|
||||
<button id="aiChatBubble" class="ai-chat-bubble" onclick="toggleAIChat()" aria-label="AI Assistant">
|
||||
<i class="fas fa-robot"></i>
|
||||
</button>
|
||||
|
||||
<!-- Floating Back Button (Mobile) -->
|
||||
<button id="floatingBackBtn" class="yt-floating-back" onclick="history.back()" aria-label="Go Back">
|
||||
<i class="fas fa-arrow-left"></i>
|
||||
|
|
@ -562,12 +561,17 @@
|
|||
|
||||
window.toggleAIChat = function () {
|
||||
const panel = document.getElementById('aiChatPanel');
|
||||
const bubble = document.getElementById('aiChatBubble');
|
||||
if (!panel) return;
|
||||
|
||||
aiChatVisible = !aiChatVisible;
|
||||
|
||||
if (aiChatVisible) {
|
||||
panel.classList.add('visible');
|
||||
if (bubble) {
|
||||
bubble.classList.add('active');
|
||||
bubble.innerHTML = '<i class="fas fa-times"></i>';
|
||||
}
|
||||
|
||||
// Initialize AI on first open
|
||||
if (!aiInitialized && window.transcriptAI && !window.transcriptAI.isModelLoading()) {
|
||||
|
|
@ -575,6 +579,10 @@
|
|||
}
|
||||
} else {
|
||||
panel.classList.remove('visible');
|
||||
if (bubble) {
|
||||
bubble.classList.remove('active');
|
||||
bubble.innerHTML = '<i class="fas fa-robot"></i>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
0
templates/login.html
Normal file → Executable file
0
templates/login.html
Normal file → Executable file
0
templates/my_videos.html
Normal file → Executable file
0
templates/my_videos.html
Normal file → Executable file
0
templates/register.html
Normal file → Executable file
0
templates/register.html
Normal file → Executable file
0
templates/settings.html
Normal file → Executable file
0
templates/settings.html
Normal file → Executable file
14
templates/watch.html
Normal file → Executable file
14
templates/watch.html
Normal file → Executable file
|
|
@ -936,7 +936,9 @@
|
|||
|
||||
// Initialize view mode from localStorage
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const savedMode = localStorage.getItem('kv_view_mode') || 'theater';
|
||||
// On mobile, always use theater mode for better viewing
|
||||
const isMobile = window.innerWidth <= 1024;
|
||||
const savedMode = isMobile ? 'theater' : (localStorage.getItem('kv_view_mode') || 'theater');
|
||||
setViewMode(savedMode);
|
||||
|
||||
// Listen for PiP exit
|
||||
|
|
@ -1355,9 +1357,11 @@
|
|||
|
||||
if (data.related && data.related.length > 0) {
|
||||
renderRelated(data.related);
|
||||
} else {
|
||||
// No initial related videos, trigger load immediately on mobile
|
||||
relatedPage = 1; // Start from page 1
|
||||
setTimeout(() => loadMoreRelated(), 500);
|
||||
}
|
||||
// If no related videos initially, do nothing.
|
||||
// The sentinel below will trigger the lazy loader to fetch them.
|
||||
|
||||
// Add Sentinel for infinite scroll
|
||||
const sentinel = document.createElement('div');
|
||||
|
|
@ -1370,7 +1374,9 @@
|
|||
|
||||
// Set global title for pagination
|
||||
currentVideoTitle = data.title;
|
||||
relatedPage = 2; // Next page is 2
|
||||
if (data.related && data.related.length > 0) {
|
||||
relatedPage = 2; // Next page is 2 if we had initial data
|
||||
}
|
||||
})(); // End initWatchPage IIFE
|
||||
|
||||
function formatViews(views) {
|
||||
|
|
|
|||
0
update_and_restart.sh
Normal file → Executable file
0
update_and_restart.sh
Normal file → Executable file
Loading…
Reference in a new issue