feat: Grid layout, feed freshness, and feature cleanup

This commit is contained in:
KV-Tube Deployer 2026-01-01 20:08:35 +07:00
parent a988bdaac3
commit 0ebea200b0
9 changed files with 332 additions and 101 deletions

79
app.py
View file

@ -13,7 +13,15 @@ from functools import wraps
from youtube_transcript_api import YouTubeTranscriptApi, TranscriptsDisabled
import re
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.secret_key = 'super_secret_key_change_this' # Required for sessions
@ -59,7 +67,11 @@ def init_db():
# Run init
init_db()
# Transcription Task Status
transcription_tasks = {}
def get_db_connection():
conn = sqlite3.connect(DB_NAME)
conn.row_factory = sqlite3.Row
return conn
@ -151,11 +163,44 @@ def save_video():
return jsonify({'status': 'already_saved'})
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.close()
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>')
def stream_local(filename):
return send_from_directory(VIDEO_DIR, filename)
@ -975,6 +1020,9 @@ def trending():
base = queries.get(cat, 'trending')
if s_sort == 'newest':
return base + ', today' # Or use explicit date filter
from datetime import datetime, timedelta
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}")
sort = request.args.get('sort', 'newest') # Ensure newest is default
# === Parallel Fetching for Home Feed ===
if category == 'all':
sections_to_fetch = [
{'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': 'music', 'title': 'Music', 'icon': 'music'},
{'id': 'movies', 'title': 'Movies', 'icon': 'film'},
@ -1000,17 +1051,19 @@ def trending():
]
def fetch_section(section):
q = get_query(section['id'], region, sort)
# Fetch 80 videos per section to guarantee density (target: 50+ after filters)
vids = fetch_videos(q, limit=80, filter_type='video', playlist_start=1)
target_sort = 'newest' if section['id'] != 'trending' else 'relevance'
q = get_query(section['id'], region, target_sort)
# 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 {
'id': section['id'],
'title': section['title'],
'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))
return jsonify({'mode': 'sections', 'data': results})
@ -1029,8 +1082,14 @@ def trending():
filter_mode = 'short' if category == 'shorts' else 'video'
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)
except Exception as e:
return jsonify({'error': str(e)}), 500
@ -1099,5 +1158,9 @@ def get_comments():
except Exception as e:
return jsonify({'comments': [], 'count': 0, 'error': str(e)})
# --- AI Transcription REMOVED ---
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
View 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
View 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

View file

@ -1,6 +1,6 @@
Product Requirements Document (PRD) - KCTube
Product Requirements Document (PRD) - KV-Tube
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
The Binge Watcher: Wants uninterrupted access to YouTube content and movies without ads.

View file

@ -1,16 +1,7 @@
flask==3.0.2
requests==2.31.0
pytube==15.0.0
python-dotenv==1.0.1
yt-dlp
moviepy
numpy
google-generativeai==0.8.3
flask-cors==4.0.0
youtube-transcript-api==0.6.2
werkzeug==3.0.1
Pillow
pysrt==1.1.2
cairosvg
gunicorn==21.2.0
gunicorn==21.2.0
python-dotenv==1.0.1

View file

@ -407,24 +407,45 @@ button {
/* ===== Video Grid ===== */
.yt-video-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 16px 16px;
grid-template-columns: repeat(4, 1fr);
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 {
display: grid;
grid-template-rows: repeat(4, min-content);
/* Force 4 rows */
grid-auto-flow: column;
/* Fill columns first (horizontal scroll) */
grid-auto-columns: 280px;
/* Fixed column width */
grid-template-columns: repeat(4, 1fr);
gap: 16px;
overflow-x: auto;
padding-bottom: 16px;
/* Space for scrollbar if any (hidden typically) */
scrollbar-width: none;
padding-bottom: 24px;
}
.yt-section-grid .yt-video-card {
width: 100%;
min-width: 0;
}
@media (max-width: 768px) {
@ -527,6 +548,7 @@ button {
color: var(--yt-text-primary);
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
margin-bottom: 4px;
@ -1246,6 +1268,7 @@ button {
color: var(--yt-text-primary);
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;

View file

@ -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
await loadTrending(true);
// Also reload shorts to match category
if (typeof loadShorts === 'function') {
loadShorts();
@ -235,12 +252,15 @@ async function loadTrending(reset = true) {
}
try {
// Get sort and region values from page (if available)
const sortValue = window.currentSort || 'month';
// Default to 'newest' for fresh content on main page
const sortValue = window.currentSort || (currentCategory === 'all' ? 'newest' : 'month');
const regionValue = window.currentRegion || 'vietnam';
const response = await fetch(`/api/trending?category=${currentCategory}&page=${currentPage}&sort=${sortValue}&region=${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}&region=${regionValue}${cb}`);
const data = await response.json();
if (data.error) {
console.error('Trending error:', data.error);
if (reset) {

View file

@ -11,16 +11,19 @@
<!-- Filters & Categories -->
<div class="yt-filter-bar">
<div class="yt-categories" id="categoryList">
<!-- "All" removed, starting with Tech -->
<button class="yt-chip" onclick="switchCategory('tech')">Tech</button>
<button class="yt-chip" onclick="switchCategory('music')">Music</button>
<button class="yt-chip" onclick="switchCategory('movies')">Movies</button>
<button class="yt-chip" onclick="switchCategory('news')">News</button>
<button class="yt-chip" onclick="switchCategory('trending')">Trending</button>
<button class="yt-chip" onclick="switchCategory('podcasts')">Podcasts</button>
<button class="yt-chip" onclick="switchCategory('live')">Live</button>
<button class="yt-chip" onclick="switchCategory('gaming')">Gaming</button>
<button class="yt-chip" onclick="switchCategory('sports')">Sports</button>
<!-- Pinned Categories -->
<button class="yt-chip" onclick="switchCategory('history', this)"><i class="fas fa-history"></i> Watched</button>
<button class="yt-chip" onclick="switchCategory('suggested', this)"><i class="fas fa-magic"></i> Suggested</button>
<!-- Standard Categories -->
<button class="yt-chip" onclick="switchCategory('tech', this)">Tech</button>
<button class="yt-chip" onclick="switchCategory('music', this)">Music</button>
<button class="yt-chip" onclick="switchCategory('movies', this)">Movies</button>
<button class="yt-chip" onclick="switchCategory('news', this)">News</button>
<button class="yt-chip" onclick="switchCategory('trending', this)">Trending</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 class="yt-filter-actions">

View file

@ -63,19 +63,14 @@
Queue
<span id="queueBadge" class="queue-badge" style="display:none;">0</span>
</button>
<button class="yt-action-btn" id="summarizeBtn" onclick="summarizeVideo()">
<i class="fas fa-magic"></i>
Summarize
</button>
<!-- Summarize button removed -->
<!-- Transcribe button removed -->
<!-- Rotation controls removed -->
</div>
<!-- Summary Box (Hidden by default) -->
<div class="yt-description-box" id="summaryBox"
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>
<!-- Summary Box removed -->
<!-- Channel Info -->
<div class="yt-channel-info">
@ -710,7 +705,14 @@
uploader: ""
};
let currentRotation = 0;
// Rotation function removed
// Transcription functions removed
function initArtplayer(url, poster, subtitleUrl = '', type = 'auto') {
// Store art instance globally for other functions
window.art = null;
// Update currentVideoData with poster (thumbnail)
if (poster) {
// Use stable YouTube thumbnail URL to prevent expiration of signed URLs
@ -732,7 +734,7 @@
}, 2000);
}
const art = new Artplayer({
window.art = new Artplayer({
container: '#artplayer-app',
url: url,
poster: poster,