feat: Grid layout, feed freshness, and feature cleanup
This commit is contained in:
parent
a988bdaac3
commit
0ebea200b0
9 changed files with 332 additions and 101 deletions
81
app.py
81
app.py
|
|
@ -13,7 +13,15 @@ from functools import wraps
|
||||||
from youtube_transcript_api import YouTubeTranscriptApi, TranscriptsDisabled
|
from youtube_transcript_api import YouTubeTranscriptApi, TranscriptsDisabled
|
||||||
import re
|
import re
|
||||||
import heapq
|
import heapq
|
||||||
# nltk removed to avoid SSL/download issues. Using regex instead.
|
import threading
|
||||||
|
import uuid
|
||||||
|
import datetime
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Fix for OMP: Error #15
|
||||||
|
os.environ["KMP_DUPLICATE_LIB_OK"] = "TRUE"
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.secret_key = 'super_secret_key_change_this' # Required for sessions
|
app.secret_key = 'super_secret_key_change_this' # Required for sessions
|
||||||
|
|
@ -59,7 +67,11 @@ def init_db():
|
||||||
# Run init
|
# Run init
|
||||||
init_db()
|
init_db()
|
||||||
|
|
||||||
|
# Transcription Task Status
|
||||||
|
transcription_tasks = {}
|
||||||
|
|
||||||
def get_db_connection():
|
def get_db_connection():
|
||||||
|
|
||||||
conn = sqlite3.connect(DB_NAME)
|
conn = sqlite3.connect(DB_NAME)
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
return conn
|
return conn
|
||||||
|
|
@ -151,11 +163,44 @@ def save_video():
|
||||||
return jsonify({'status': 'already_saved'})
|
return jsonify({'status': 'already_saved'})
|
||||||
|
|
||||||
conn.execute('INSERT INTO user_videos (user_id, video_id, title, thumbnail, type) VALUES (?, ?, ?, ?, ?)',
|
conn.execute('INSERT INTO user_videos (user_id, video_id, title, thumbnail, type) VALUES (?, ?, ?, ?, ?)',
|
||||||
(session['user_id'], video_id, title, thumbnail, action_type))
|
(1, video_id, title, thumbnail, action_type)) # Default user_id 1
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
return jsonify({'status': 'success'})
|
return jsonify({'status': 'success'})
|
||||||
|
|
||||||
|
@app.route('/api/history')
|
||||||
|
def get_history():
|
||||||
|
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])
|
||||||
|
|
||||||
|
@app.route('/api/suggested')
|
||||||
|
def get_suggested():
|
||||||
|
# Simple recommendation based on history: search for "trending" related to the last 3 viewed channels/titles
|
||||||
|
conn = get_db_connection()
|
||||||
|
history = conn.execute('SELECT title FROM user_videos WHERE type = "history" ORDER BY timestamp DESC LIMIT 3').fetchall()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if not history:
|
||||||
|
return jsonify(fetch_videos("trending", limit=20))
|
||||||
|
|
||||||
|
all_suggestions = []
|
||||||
|
with concurrent.futures.ThreadPoolExecutor(max_workers=3) as executor:
|
||||||
|
queries = [f"{row['title']} related" for row in history]
|
||||||
|
results = list(executor.map(lambda q: fetch_videos(q, limit=10), queries))
|
||||||
|
for res in results:
|
||||||
|
all_suggestions.extend(res)
|
||||||
|
|
||||||
|
# Remove duplicates and shuffle
|
||||||
|
unique_vids = {v['id']: v for v in all_suggestions}.values()
|
||||||
|
import random
|
||||||
|
final_list = list(unique_vids)
|
||||||
|
random.shuffle(final_list)
|
||||||
|
|
||||||
|
return jsonify(final_list[:30])
|
||||||
|
|
||||||
|
|
||||||
@app.route('/stream/<path:filename>')
|
@app.route('/stream/<path:filename>')
|
||||||
def stream_local(filename):
|
def stream_local(filename):
|
||||||
return send_from_directory(VIDEO_DIR, filename)
|
return send_from_directory(VIDEO_DIR, filename)
|
||||||
|
|
@ -615,7 +660,7 @@ def get_stream_info():
|
||||||
response = jsonify(response_data)
|
response = jsonify(response_data)
|
||||||
response.headers['X-Cache'] = 'MISS'
|
response.headers['X-Cache'] = 'MISS'
|
||||||
return response
|
return response
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'error': str(e)}), 500
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
@ -975,6 +1020,9 @@ def trending():
|
||||||
|
|
||||||
base = queries.get(cat, 'trending')
|
base = queries.get(cat, 'trending')
|
||||||
|
|
||||||
|
if s_sort == 'newest':
|
||||||
|
return base + ', today' # Or use explicit date filter
|
||||||
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
three_months_ago = (datetime.now() - timedelta(days=90)).strftime('%Y-%m-%d')
|
three_months_ago = (datetime.now() - timedelta(days=90)).strftime('%Y-%m-%d')
|
||||||
|
|
||||||
|
|
@ -987,10 +1035,13 @@ def trending():
|
||||||
}
|
}
|
||||||
return base + sort_filters.get(s_sort, f" after:{three_months_ago}")
|
return base + sort_filters.get(s_sort, f" after:{three_months_ago}")
|
||||||
|
|
||||||
|
sort = request.args.get('sort', 'newest') # Ensure newest is default
|
||||||
|
|
||||||
# === Parallel Fetching for Home Feed ===
|
# === Parallel Fetching for Home Feed ===
|
||||||
if category == 'all':
|
if category == 'all':
|
||||||
sections_to_fetch = [
|
sections_to_fetch = [
|
||||||
{'id': 'trending', 'title': 'Trending Now', 'icon': 'fire'},
|
{'id': 'trending', 'title': 'Trending Now', 'icon': 'fire'},
|
||||||
|
{'id': 'all', 'title': 'New Releases', 'icon': 'clock'}, # 'all' results in trending, but we'll sort by newest
|
||||||
{'id': 'tech', 'title': 'AI & Tech', 'icon': 'microchip'},
|
{'id': 'tech', 'title': 'AI & Tech', 'icon': 'microchip'},
|
||||||
{'id': 'music', 'title': 'Music', 'icon': 'music'},
|
{'id': 'music', 'title': 'Music', 'icon': 'music'},
|
||||||
{'id': 'movies', 'title': 'Movies', 'icon': 'film'},
|
{'id': 'movies', 'title': 'Movies', 'icon': 'film'},
|
||||||
|
|
@ -1000,17 +1051,19 @@ def trending():
|
||||||
]
|
]
|
||||||
|
|
||||||
def fetch_section(section):
|
def fetch_section(section):
|
||||||
q = get_query(section['id'], region, sort)
|
target_sort = 'newest' if section['id'] != 'trending' else 'relevance'
|
||||||
# Fetch 80 videos per section to guarantee density (target: 50+ after filters)
|
q = get_query(section['id'], region, target_sort)
|
||||||
vids = fetch_videos(q, limit=80, filter_type='video', playlist_start=1)
|
# Add a unique component to query for freshness
|
||||||
|
q_fresh = f"{q} {int(time.time())}" if section['id'] == 'all' else q
|
||||||
|
vids = fetch_videos(q_fresh, limit=20, filter_type='video', playlist_start=1)
|
||||||
return {
|
return {
|
||||||
'id': section['id'],
|
'id': section['id'],
|
||||||
'title': section['title'],
|
'title': section['title'],
|
||||||
'icon': section['icon'],
|
'icon': section['icon'],
|
||||||
'videos': vids[:60]
|
'videos': vids[:16]
|
||||||
}
|
}
|
||||||
|
|
||||||
with concurrent.futures.ThreadPoolExecutor(max_workers=7) as executor:
|
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as executor:
|
||||||
results = list(executor.map(fetch_section, sections_to_fetch))
|
results = list(executor.map(fetch_section, sections_to_fetch))
|
||||||
|
|
||||||
return jsonify({'mode': 'sections', 'data': results})
|
return jsonify({'mode': 'sections', 'data': results})
|
||||||
|
|
@ -1029,8 +1082,14 @@ def trending():
|
||||||
filter_mode = 'short' if category == 'shorts' else 'video'
|
filter_mode = 'short' if category == 'shorts' else 'video'
|
||||||
|
|
||||||
results = fetch_videos(query, limit=limit, filter_type=filter_mode, playlist_start=start)
|
results = fetch_videos(query, limit=limit, filter_type=filter_mode, playlist_start=start)
|
||||||
|
# Randomize a bit for "freshness" if it's the first page
|
||||||
|
if page == 1:
|
||||||
|
import random
|
||||||
|
random.shuffle(results)
|
||||||
|
|
||||||
return jsonify(results)
|
return jsonify(results)
|
||||||
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'error': str(e)}), 500
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
@ -1099,5 +1158,9 @@ def get_comments():
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'comments': [], 'count': 0, 'error': str(e)})
|
return jsonify({'comments': [], 'count': 0, 'error': str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
# --- AI Transcription REMOVED ---
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
app.run(debug=True, host='0.0.0.0', port=5001)
|
print("Starting KV-Tube Server on port 5002 (Reloader Disabled)")
|
||||||
|
app.run(debug=True, host='0.0.0.0', port=5002, use_reloader=False)
|
||||||
|
|
|
||||||
66
deploy-docker.bat
Normal file
66
deploy-docker.bat
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
@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
|
||||||
63
deploy-docker.ps1
Normal file
63
deploy-docker.ps1
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
#!/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
|
||||||
|
|
@ -1,46 +1,46 @@
|
||||||
Product Requirements Document (PRD) - KCTube
|
Product Requirements Document (PRD) - KV-Tube
|
||||||
1. Product Overview
|
1. Product Overview
|
||||||
Product Name: KCTube Version: 1.0 (In Development) Description: KCTube is a comprehensive media center web application designed to provide an ad-free YouTube experience, a curated movie streaming service, and a local video management system. It emphasizes privacy, absence of advertisements, and utility features like AI summarization and language learning tools.
|
Product Name: KV-Tube Version: 1.0 (In Development) Description: KV-Tube is a comprehensive media center web application designed to provide an ad-free YouTube experience, a curated movie streaming service, and a local video management system. It emphasizes privacy, absence of advertisements, and utility features like AI summarization and language learning tools.
|
||||||
|
|
||||||
2. User Personas
|
2. User Personas
|
||||||
The Binge Watcher: Wants uninterrupted access to YouTube content and movies without ads.
|
The Binge Watcher: Wants uninterrupted access to YouTube content and movies without ads.
|
||||||
The Archivist: Maintains a local collection of videos and wants a clean interface to organize and watch them securely.
|
The Archivist: Maintains a local collection of videos and wants a clean interface to organize and watch them securely.
|
||||||
The Learner: Uses video content for educational purposes, specifically English learning.
|
The Learner: Uses video content for educational purposes, specifically English learning.
|
||||||
The Tech Enthusiast: Appreciates PWA support, torrent integration, and customizable settings.
|
The Tech Enthusiast: Appreciates PWA support, torrent integration, and customizable settings.
|
||||||
3. Core Features
|
3. Core Features
|
||||||
3.1. YouTube Viewer (Home)
|
3.1. YouTube Viewer (Home)
|
||||||
Ad-Free Experience: Plays YouTube videos without third-party advertisements.
|
Ad-Free Experience: Plays YouTube videos without third-party advertisements.
|
||||||
Search: Integrated search bar powered by yt-dlp to find videos, channels, and playlists.
|
Search: Integrated search bar powered by yt-dlp to find videos, channels, and playlists.
|
||||||
Playback: Custom video player with support for quality selection and playback speed.
|
Playback: Custom video player with support for quality selection and playback speed.
|
||||||
AI Summarization: Feature to summarize video content using Google Gemini API (Optional).
|
AI Summarization: Feature to summarize video content using Google Gemini API (Optional).
|
||||||
3.2. local Video Manager ("My Videos")
|
3.2. local Video Manager ("My Videos")
|
||||||
Secure Access: Password-protected section for personal video collections.
|
Secure Access: Password-protected section for personal video collections.
|
||||||
File Management: Scans local directories for video files.
|
File Management: Scans local directories for video files.
|
||||||
Metadata: Extracts metadata (duration, format) and generates thumbnails using FFmpeg/MoviePy.
|
Metadata: Extracts metadata (duration, format) and generates thumbnails using FFmpeg/MoviePy.
|
||||||
Playback: Native HTML5 player for local files.
|
Playback: Native HTML5 player for local files.
|
||||||
3.3. Utilities
|
3.3. Utilities
|
||||||
Torrent Player: Interface for streaming/playing video content via torrents.
|
Torrent Player: Interface for streaming/playing video content via torrents.
|
||||||
Playlist Manager: Create and manage custom playlists of YouTube videos.
|
Playlist Manager: Create and manage custom playlists of YouTube videos.
|
||||||
Camera/Photo: ("Chụp ảnh") Feature to capture or manage photos (Webcam integration).
|
Camera/Photo: ("Chụp ảnh") Feature to capture or manage photos (Webcam integration).
|
||||||
Configuration: Web-based settings to manage application behavior (e.g., password, storage paths).
|
Configuration: Web-based settings to manage application behavior (e.g., password, storage paths).
|
||||||
4. Technical Architecture
|
4. Technical Architecture
|
||||||
Backend: Python / Flask
|
Backend: Python / Flask
|
||||||
Frontend: HTML5, CSS3, JavaScript (Vanilla)
|
Frontend: HTML5, CSS3, JavaScript (Vanilla)
|
||||||
Database/Storage: JSON-based local storage and file system.
|
Database/Storage: JSON-based local storage and file system.
|
||||||
Video Processing: yt-dlp (YouTube), FFmpeg (Conversion/Thumbnail), MoviePy (Optional).
|
Video Processing: yt-dlp (YouTube), FFmpeg (Conversion/Thumbnail), MoviePy (Optional).
|
||||||
AI Service: Google Gemini API (for summarization).
|
AI Service: Google Gemini API (for summarization).
|
||||||
Deployment: Docker container support (xehopnet/kctube).
|
Deployment: Docker container support (xehopnet/kctube).
|
||||||
5. Non-Functional Requirements
|
5. Non-Functional Requirements
|
||||||
Performance: Fast load times and responsive UI.
|
Performance: Fast load times and responsive UI.
|
||||||
Compatibility: PWA-ready for installation on desktop and mobile.
|
Compatibility: PWA-ready for installation on desktop and mobile.
|
||||||
Reliability: graceful degradation if optional dependencies (MoviePy, Gemini) are missing.
|
Reliability: graceful degradation if optional dependencies (MoviePy, Gemini) are missing.
|
||||||
Privacy: No user tracking or external analytics.
|
Privacy: No user tracking or external analytics.
|
||||||
6. Known Limitations
|
6. Known Limitations
|
||||||
Search Reliability: Dependent on yt-dlp stability and YouTube's anti-bot measures.
|
Search Reliability: Dependent on yt-dlp stability and YouTube's anti-bot measures.
|
||||||
External APIs: Movie features rely on third-party APIs which may have downtime.
|
External APIs: Movie features rely on third-party APIs which may have downtime.
|
||||||
Dependency Management: Some Python libraries (MoviePy, Numpy) require compilation tools.
|
Dependency Management: Some Python libraries (MoviePy, Numpy) require compilation tools.
|
||||||
7. Future Roadmap
|
7. Future Roadmap
|
||||||
Database: Migrate from JSON to SQLite for better performance with large libraries.
|
Database: Migrate from JSON to SQLite for better performance with large libraries.
|
||||||
User Accounts: Individual user profiles and history.
|
User Accounts: Individual user profiles and history.
|
||||||
Offline Mode: Enhanced offline capabilities for PWA.
|
Offline Mode: Enhanced offline capabilities for PWA.
|
||||||
Casting: Support for Chromecast/AirPlay.
|
Casting: Support for Chromecast/AirPlay.
|
||||||
|
|
@ -1,16 +1,7 @@
|
||||||
flask==3.0.2
|
flask==3.0.2
|
||||||
requests==2.31.0
|
requests==2.31.0
|
||||||
pytube==15.0.0
|
|
||||||
python-dotenv==1.0.1
|
|
||||||
yt-dlp
|
yt-dlp
|
||||||
moviepy
|
|
||||||
numpy
|
|
||||||
google-generativeai==0.8.3
|
|
||||||
flask-cors==4.0.0
|
|
||||||
youtube-transcript-api==0.6.2
|
youtube-transcript-api==0.6.2
|
||||||
werkzeug==3.0.1
|
werkzeug==3.0.1
|
||||||
Pillow
|
|
||||||
pysrt==1.1.2
|
|
||||||
cairosvg
|
|
||||||
gunicorn==21.2.0
|
|
||||||
gunicorn==21.2.0
|
gunicorn==21.2.0
|
||||||
|
python-dotenv==1.0.1
|
||||||
|
|
|
||||||
|
|
@ -407,24 +407,45 @@ button {
|
||||||
/* ===== Video Grid ===== */
|
/* ===== Video Grid ===== */
|
||||||
.yt-video-grid {
|
.yt-video-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
grid-template-columns: repeat(4, 1fr);
|
||||||
gap: 16px 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 4-Row Horizontal Grid for Sections */
|
@media (max-width: 1400px) {
|
||||||
|
|
||||||
|
.yt-video-grid,
|
||||||
|
.yt-section-grid {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
|
||||||
|
.yt-video-grid,
|
||||||
|
.yt-section-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
|
||||||
|
.yt-video-grid,
|
||||||
|
.yt-section-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Grid Layout for Sections (4 rows x 4 columns = 16 videos) */
|
||||||
.yt-section-grid {
|
.yt-section-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: repeat(4, min-content);
|
grid-template-columns: repeat(4, 1fr);
|
||||||
/* Force 4 rows */
|
|
||||||
grid-auto-flow: column;
|
|
||||||
/* Fill columns first (horizontal scroll) */
|
|
||||||
grid-auto-columns: 280px;
|
|
||||||
/* Fixed column width */
|
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
overflow-x: auto;
|
padding-bottom: 24px;
|
||||||
padding-bottom: 16px;
|
}
|
||||||
/* Space for scrollbar if any (hidden typically) */
|
|
||||||
scrollbar-width: none;
|
.yt-section-grid .yt-video-card {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
|
@ -527,6 +548,7 @@ button {
|
||||||
color: var(--yt-text-primary);
|
color: var(--yt-text-primary);
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-line-clamp: 2;
|
-webkit-line-clamp: 2;
|
||||||
|
line-clamp: 2;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
|
|
@ -1246,6 +1268,7 @@ button {
|
||||||
color: var(--yt-text-primary);
|
color: var(--yt-text-primary);
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-line-clamp: 2;
|
-webkit-line-clamp: 2;
|
||||||
|
line-clamp: 2;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
|
|
||||||
|
|
@ -197,9 +197,26 @@ async function switchCategory(category, btn) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle Special Categories
|
||||||
|
if (category === 'history') {
|
||||||
|
const response = await fetch('/api/history');
|
||||||
|
const data = await response.json();
|
||||||
|
displayResults(data, false);
|
||||||
|
isLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (category === 'suggested') {
|
||||||
|
const response = await fetch('/api/suggested');
|
||||||
|
const data = await response.json();
|
||||||
|
displayResults(data, false);
|
||||||
|
isLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Load both videos and shorts with current category, sort, and region
|
// Load both videos and shorts with current category, sort, and region
|
||||||
await loadTrending(true);
|
await loadTrending(true);
|
||||||
|
|
||||||
|
|
||||||
// Also reload shorts to match category
|
// Also reload shorts to match category
|
||||||
if (typeof loadShorts === 'function') {
|
if (typeof loadShorts === 'function') {
|
||||||
loadShorts();
|
loadShorts();
|
||||||
|
|
@ -235,12 +252,15 @@ async function loadTrending(reset = true) {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get sort and region values from page (if available)
|
// Default to 'newest' for fresh content on main page
|
||||||
const sortValue = window.currentSort || 'month';
|
const sortValue = window.currentSort || (currentCategory === 'all' ? 'newest' : 'month');
|
||||||
const regionValue = window.currentRegion || 'vietnam';
|
const regionValue = window.currentRegion || 'vietnam';
|
||||||
const response = await fetch(`/api/trending?category=${currentCategory}&page=${currentPage}&sort=${sortValue}®ion=${regionValue}`);
|
// Add cache-buster for home page to ensure fresh content
|
||||||
|
const cb = reset && currentCategory === 'all' ? `&_=${Date.now()}` : '';
|
||||||
|
const response = await fetch(`/api/trending?category=${currentCategory}&page=${currentPage}&sort=${sortValue}®ion=${regionValue}${cb}`);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
|
|
||||||
if (data.error) {
|
if (data.error) {
|
||||||
console.error('Trending error:', data.error);
|
console.error('Trending error:', data.error);
|
||||||
if (reset) {
|
if (reset) {
|
||||||
|
|
|
||||||
|
|
@ -11,16 +11,19 @@
|
||||||
<!-- Filters & Categories -->
|
<!-- Filters & Categories -->
|
||||||
<div class="yt-filter-bar">
|
<div class="yt-filter-bar">
|
||||||
<div class="yt-categories" id="categoryList">
|
<div class="yt-categories" id="categoryList">
|
||||||
<!-- "All" removed, starting with Tech -->
|
<!-- Pinned Categories -->
|
||||||
<button class="yt-chip" onclick="switchCategory('tech')">Tech</button>
|
<button class="yt-chip" onclick="switchCategory('history', this)"><i class="fas fa-history"></i> Watched</button>
|
||||||
<button class="yt-chip" onclick="switchCategory('music')">Music</button>
|
<button class="yt-chip" onclick="switchCategory('suggested', this)"><i class="fas fa-magic"></i> Suggested</button>
|
||||||
<button class="yt-chip" onclick="switchCategory('movies')">Movies</button>
|
<!-- Standard Categories -->
|
||||||
<button class="yt-chip" onclick="switchCategory('news')">News</button>
|
<button class="yt-chip" onclick="switchCategory('tech', this)">Tech</button>
|
||||||
<button class="yt-chip" onclick="switchCategory('trending')">Trending</button>
|
<button class="yt-chip" onclick="switchCategory('music', this)">Music</button>
|
||||||
<button class="yt-chip" onclick="switchCategory('podcasts')">Podcasts</button>
|
<button class="yt-chip" onclick="switchCategory('movies', this)">Movies</button>
|
||||||
<button class="yt-chip" onclick="switchCategory('live')">Live</button>
|
<button class="yt-chip" onclick="switchCategory('news', this)">News</button>
|
||||||
<button class="yt-chip" onclick="switchCategory('gaming')">Gaming</button>
|
<button class="yt-chip" onclick="switchCategory('trending', this)">Trending</button>
|
||||||
<button class="yt-chip" onclick="switchCategory('sports')">Sports</button>
|
<button class="yt-chip" onclick="switchCategory('podcasts', this)">Podcasts</button>
|
||||||
|
<button class="yt-chip" onclick="switchCategory('live', this)">Live</button>
|
||||||
|
<button class="yt-chip" onclick="switchCategory('gaming', this)">Gaming</button>
|
||||||
|
<button class="yt-chip" onclick="switchCategory('sports', this)">Sports</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="yt-filter-actions">
|
<div class="yt-filter-actions">
|
||||||
|
|
|
||||||
|
|
@ -63,19 +63,14 @@
|
||||||
Queue
|
Queue
|
||||||
<span id="queueBadge" class="queue-badge" style="display:none;">0</span>
|
<span id="queueBadge" class="queue-badge" style="display:none;">0</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="yt-action-btn" id="summarizeBtn" onclick="summarizeVideo()">
|
<!-- Summarize button removed -->
|
||||||
<i class="fas fa-magic"></i>
|
<!-- Transcribe button removed -->
|
||||||
Summarize
|
<!-- Rotation controls removed -->
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<!-- Summary Box (Hidden by default) -->
|
<!-- Summary Box (Hidden by default) -->
|
||||||
<div class="yt-description-box" id="summaryBox"
|
<!-- Summary Box removed -->
|
||||||
style="display:none; margin-bottom:16px; border: 1px solid var(--yt-accent-blue); background: rgba(62, 166, 255, 0.1);">
|
|
||||||
<p class="yt-description-stats" style="color: var(--yt-accent-blue);"><i class="fas fa-sparkles"></i> AI
|
|
||||||
Summary</p>
|
|
||||||
<p class="yt-description-text" id="summaryText">Generating summary...</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Channel Info -->
|
<!-- Channel Info -->
|
||||||
<div class="yt-channel-info">
|
<div class="yt-channel-info">
|
||||||
|
|
@ -710,7 +705,14 @@
|
||||||
uploader: ""
|
uploader: ""
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let currentRotation = 0;
|
||||||
|
// Rotation function removed
|
||||||
|
|
||||||
|
// Transcription functions removed
|
||||||
|
|
||||||
function initArtplayer(url, poster, subtitleUrl = '', type = 'auto') {
|
function initArtplayer(url, poster, subtitleUrl = '', type = 'auto') {
|
||||||
|
// Store art instance globally for other functions
|
||||||
|
window.art = null;
|
||||||
// Update currentVideoData with poster (thumbnail)
|
// Update currentVideoData with poster (thumbnail)
|
||||||
if (poster) {
|
if (poster) {
|
||||||
// Use stable YouTube thumbnail URL to prevent expiration of signed URLs
|
// Use stable YouTube thumbnail URL to prevent expiration of signed URLs
|
||||||
|
|
@ -732,7 +734,7 @@
|
||||||
}, 2000);
|
}, 2000);
|
||||||
}
|
}
|
||||||
|
|
||||||
const art = new Artplayer({
|
window.art = new Artplayer({
|
||||||
container: '#artplayer-app',
|
container: '#artplayer-app',
|
||||||
url: url,
|
url: url,
|
||||||
poster: poster,
|
poster: poster,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue