Fix subscribe button and library tab navigation

This commit is contained in:
KV-Tube Deployer 2026-01-11 21:51:42 +07:00
parent 38449d23d6
commit 60d3bc3a5e
11 changed files with 1034 additions and 329 deletions

12
.dockerignore Normal file
View file

@ -0,0 +1,12 @@
__pycache__
.venv
.git
.env
*.mp4
*.webm
*.mp3
videos/
data/
temp/
deployment_package/
kvtube.db

27
deploy_v2.ps1 Normal file
View file

@ -0,0 +1,27 @@
# 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

View file

@ -120,16 +120,37 @@
.yt-sidebar.collapsed .yt-sidebar-item {
flex-direction: column;
gap: 6px;
gap: 0;
padding: 16px 0;
margin: 0;
border-radius: 0;
justify-content: center;
align-items: center;
text-align: center;
}
/* Hide text labels in collapsed mode - icons only */
.yt-sidebar.collapsed .yt-sidebar-item span {
font-size: 10px;
display: none;
}
/* Center icons in collapsed mode */
.yt-sidebar.collapsed .yt-sidebar-item i {
font-size: 20px;
width: 100%;
text-align: center;
}
/* Hide Saved, Subscriptions, dividers and titles in collapsed mode */
.yt-sidebar.collapsed .yt-sidebar-title,
.yt-sidebar.collapsed .yt-sidebar-divider {
display: none;
}
/* Hide Saved and Subscriptions globally (both full and collapsed sidebar) */
.yt-sidebar a[data-category="saved"],
.yt-sidebar a[data-category="subscriptions"] {
display: none;
}
.yt-sidebar-divider {

View file

@ -1,9 +1,9 @@
/* ===== Watch Page ===== */
/* Layout rules moved to watch.css - this is kept for compatibility */
.yt-watch-layout {
display: grid;
grid-template-columns: 1fr 400px;
gap: 24px;
max-width: 1800px;
}
.yt-player-section {

View file

@ -90,33 +90,118 @@ body {
}
/* ========== Watch Page Layout ========== */
.yt-main {
/* Only apply these overrides when the watch layout is present */
.yt-main:has(.yt-watch-layout) {
padding: 0 !important;
margin-left: 240px;
/* Auto-collapse main content margin on watch page to match collapsed sidebar */
margin-left: var(--yt-sidebar-mini) !important;
}
/* Auto-collapse sidebar on watch page */
.yt-sidebar:has(~ .yt-sidebar-overlay ~ .yt-main .yt-watch-layout),
body:has(.yt-watch-layout) .yt-sidebar {
width: var(--yt-sidebar-mini);
}
/* Sidebar item styling for mini mode on watch page */
body:has(.yt-watch-layout) .yt-sidebar .yt-sidebar-item {
flex-direction: column;
gap: 0;
padding: 16px 0;
margin: 0;
border-radius: 0;
justify-content: center;
align-items: center;
text-align: center;
}
/* Hide text labels in mini mode - icons only */
body:has(.yt-watch-layout) .yt-sidebar .yt-sidebar-item span {
display: none;
}
/* Center the icons */
body:has(.yt-watch-layout) .yt-sidebar .yt-sidebar-item i {
font-size: 20px;
width: 100%;
text-align: center;
}
/* Hide Saved, Subscriptions, and dividers/titles on watch page */
body:has(.yt-watch-layout) .yt-sidebar .yt-sidebar-title,
body:has(.yt-watch-layout) .yt-sidebar .yt-sidebar-divider,
body:has(.yt-watch-layout) .yt-sidebar a[data-category="saved"],
body:has(.yt-watch-layout) .yt-sidebar a[data-category="subscriptions"] {
display: none;
}
/* Theater Mode (Default) - Full width video with sidebar below */
.yt-watch-layout {
display: flex;
flex-direction: column;
width: 100%;
padding: 8px 24px 24px;
box-sizing: border-box;
}
/* Default Mode - 2 column layout */
.yt-watch-layout.default-mode {
display: grid;
grid-template-columns: 1fr 400px;
gap: 24px;
max-width: 100%;
width: 100%;
padding: 24px;
margin: 0;
box-sizing: border-box;
}
.yt-watch-layout.default-mode .yt-watch-sidebar {
position: sticky;
top: 80px;
align-self: start;
max-height: calc(100vh - 100px);
}
/* Theater mode sidebar moves below */
.yt-watch-layout:not(.default-mode) .yt-watch-sidebar {
margin-top: 24px;
}
.yt-watch-sidebar {
display: flex;
flex-direction: column;
gap: 0;
position: sticky;
top: 80px;
align-self: start;
max-height: calc(100vh - 100px);
overflow: visible;
}
/* View Mode Button Styles */
.view-mode-buttons {
display: flex;
gap: 8px;
margin-left: auto;
}
.view-mode-btn {
width: 36px;
height: 36px;
border-radius: 50%;
border: none;
background: var(--yt-bg-secondary);
color: var(--yt-text-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.view-mode-btn:hover {
background: var(--yt-bg-hover);
color: var(--yt-text-primary);
}
.view-mode-btn.active {
background: linear-gradient(135deg, #cc0000 0%, #ff4444 100%);
color: white;
}
.yt-channel-avatar-lg {
width: 40px;
height: 40px;

View file

@ -245,7 +245,18 @@ async function switchCategory(category, btn) {
return;
}
if (category === 'suggested') {
const response = await fetch('/api/suggested');
// Build query params from localStorage history
const history = JSON.parse(localStorage.getItem('kv_history') || '[]');
const titles = history.slice(0, 5).map(v => v.title).filter(Boolean).join(',');
const channels = history.slice(0, 3).map(v => v.uploader).filter(Boolean).join(',');
let url = '/api/suggested';
const params = new URLSearchParams();
if (titles) params.append('titles', titles);
if (channels) params.append('channels', channels);
if (params.toString()) url += '?' + params.toString();
const response = await fetch(url);
const data = await response.json();
displayResults(data, false);
isLoading = false;
@ -296,7 +307,20 @@ async function loadTrending(reset = true) {
const regionValue = window.currentRegion || 'vietnam';
// 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}`);
// Include localStorage history for personalized suggestions on home page
let historyParams = '';
if (currentCategory === 'all') {
const history = JSON.parse(localStorage.getItem('kv_history') || '[]');
if (history.length > 0) {
const titles = history.slice(0, 5).map(v => v.title).filter(Boolean).join(',');
const channels = history.slice(0, 3).map(v => v.uploader).filter(Boolean).join(',');
if (titles) historyParams += `&history_titles=${encodeURIComponent(titles)}`;
if (channels) historyParams += `&history_channels=${encodeURIComponent(channels)}`;
}
}
const response = await fetch(`/api/trending?category=${currentCategory}&page=${currentPage}&sort=${sortValue}&region=${regionValue}${historyParams}${cb}`);
const data = await response.json();

View file

@ -22,10 +22,7 @@
%}@Loading...{% endif %}
</p>
<div class="yt-channel-stats">
<span id="channelStats">Subscribe for more</span>
</div>
<div class="yt-channel-actions">
<button class="yt-subscribe-btn-lg" id="subscribeChannelBtn">Subscribe</button>
<span id="channelStats"></span>
</div>
</div>
</div>
@ -35,14 +32,29 @@
<div class="yt-section">
<div class="yt-section-header">
<div class="yt-tabs">
<a href="#" onclick="changeChannelTab('video', this); return false;" class="active">Videos</a>
<a href="#" onclick="changeChannelTab('shorts', this); return false;">Shorts</a>
<a href="#" onclick="changeChannelTab('video', this); return false;" class="active">
<i class="fas fa-video"></i>
<span>Videos</span>
</a>
<a href="#" onclick="changeChannelTab('shorts', this); return false;">
<i class="fas fa-bolt"></i>
<span>Shorts</span>
</a>
</div>
<div class="yt-sort-options">
<a href="#" onclick="changeChannelSort('latest', this); return false;" class="active">Latest</a>
<a href="#" onclick="changeChannelSort('popular', this); return false;">Popular</a>
<a href="#" onclick="changeChannelSort('oldest', this); return false;">Oldest</a>
<a href="#" onclick="changeChannelSort('latest', this); return false;" class="active">
<i class="fas fa-clock"></i>
<span>Latest</span>
</a>
<a href="#" onclick="changeChannelSort('popular', this); return false;">
<i class="fas fa-fire"></i>
<span>Popular</span>
</a>
<a href="#" onclick="changeChannelSort('oldest', this); return false;">
<i class="fas fa-history"></i>
<span>Oldest</span>
</a>
</div>
</div>
@ -129,53 +141,58 @@
.yt-tabs {
display: inline-flex;
gap: 0;
gap: 8px;
background: var(--yt-bg-secondary);
padding: 4px;
border-radius: 24px;
position: relative;
padding: 6px;
border-radius: 16px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
}
.yt-tabs a {
font-size: 14px;
display: flex;
align-items: center;
gap: 8px;
padding: 12px 24px;
border-radius: 12px;
font-size: 0.95rem;
font-weight: 600;
color: var(--yt-text-secondary);
text-decoration: none;
padding: 8px 24px;
border-radius: 20px;
border-bottom: none;
z-index: 1;
transition: color 0.2s;
position: relative;
transition: all 0.25s ease;
border: none;
background: transparent;
}
.yt-tabs a:hover {
color: var(--yt-text-primary);
background: var(--yt-bg-hover);
}
.yt-tabs a.active {
color: var(--yt-bg-primary);
background: var(--yt-text-primary);
/* The "slider" is actually the active pill moving */
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
background: linear-gradient(135deg, #cc0000 0%, #ff4444 100%);
color: white;
box-shadow: 0 4px 12px rgba(204, 0, 0, 0.3);
}
.yt-sort-options {
display: flex;
display: inline-flex;
gap: 8px;
justify-content: center;
background: var(--yt-bg-secondary);
padding: 6px;
border-radius: 16px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
}
.yt-sort-options a {
padding: 6px 16px;
border-radius: 16px;
padding: 10px 20px;
border-radius: 12px;
background: transparent;
border: 1px solid var(--yt-border);
border: none;
color: var(--yt-text-secondary);
text-decoration: none;
font-size: 13px;
font-weight: 500;
transition: all 0.2s;
font-size: 0.9rem;
font-weight: 600;
transition: all 0.25s ease;
}
.yt-sort-options a:hover {
@ -184,9 +201,9 @@
}
.yt-sort-options a.active {
background: var(--yt-bg-secondary);
color: var(--yt-text-primary);
border-color: var(--yt-text-primary);
background: linear-gradient(135deg, #cc0000 0%, #ff4444 100%);
color: white;
box-shadow: 0 4px 12px rgba(204, 0, 0, 0.3);
}
/* Shorts Card Styling override for Channel Page grid */
@ -245,133 +262,134 @@
</style>
<script>
console.log("Channel.html script loaded, channelId will be:", "{{ channel.id }}");
(function () {
// IIFE to prevent variable redeclaration errors on SPA navigation
console.log("Channel.html script loaded, channelId will be:", "{{ channel.id }}");
let currentChannelSort = 'latest';
let currentChannelPage = 1;
let isChannelLoading = false;
let hasMoreChannelVideos = true;
let currentFilterType = 'video';
const channelId = "{{ channel.id }}";
var currentChannelSort = 'latest';
var currentChannelPage = 1;
var isChannelLoading = false;
var hasMoreChannelVideos = true;
var currentFilterType = 'video';
var channelId = "{{ channel.id }}";
// Store initial channel title from server template (don't overwrite with empty API data)
var initialChannelTitle = "{{ channel.title }}";
document.addEventListener('DOMContentLoaded', () => {
console.log("DOMContentLoaded fired, calling fetchChannelContent...");
console.log("typeof fetchChannelContent:", typeof fetchChannelContent);
if (typeof fetchChannelContent === 'function') {
function init() {
console.log("Channel init called, fetching content...");
fetchChannelContent();
setupInfiniteScroll();
}
// Handle both initial page load and SPA navigation
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
console.error("fetchChannelContent is NOT a function!");
}
setupInfiniteScroll();
});
function changeChannelTab(type, btn) {
if (type === currentFilterType || isChannelLoading) return;
currentFilterType = type;
currentChannelPage = 1;
hasMoreChannelVideos = true;
document.getElementById('channelVideosGrid').innerHTML = '';
// Update Tabs UI
document.querySelectorAll('.yt-tabs a').forEach(a => a.classList.remove('active'));
btn.classList.add('active');
// Adjust Grid layout for Shorts vs Videos
const grid = document.getElementById('channelVideosGrid');
if (type === 'shorts') {
grid.style.gridTemplateColumns = 'repeat(auto-fill, minmax(200px, 1fr))';
} else {
grid.style.gridTemplateColumns = 'repeat(auto-fill, minmax(320px, 1fr))';
// DOM is already ready (SPA navigation)
init();
}
fetchChannelContent();
}
function changeChannelTab(type, btn) {
if (type === currentFilterType || isChannelLoading) return;
currentFilterType = type;
currentChannelPage = 1;
hasMoreChannelVideos = true;
document.getElementById('channelVideosGrid').innerHTML = '';
function changeChannelSort(sort, btn) {
if (isChannelLoading) return;
currentChannelSort = sort;
currentChannelPage = 1;
hasMoreChannelVideos = true;
document.getElementById('channelVideosGrid').innerHTML = ''; // Clear
// Update Tabs UI
document.querySelectorAll('.yt-tabs a').forEach(a => a.classList.remove('active'));
btn.classList.add('active');
// Update tabs
document.querySelectorAll('.yt-sort-options a').forEach(a => a.classList.remove('active'));
btn.classList.add('active');
fetchChannelContent();
}
async function fetchChannelContent() {
console.log("fetchChannelContent() called");
if (isChannelLoading || !hasMoreChannelVideos) {
console.log("Early return:", { isChannelLoading, hasMoreChannelVideos });
return;
}
isChannelLoading = true;
const grid = document.getElementById('channelVideosGrid');
// Append Loading indicator
if (typeof renderSkeleton === 'function') {
grid.insertAdjacentHTML('beforeend', renderSkeleton(4));
} else {
grid.insertAdjacentHTML('beforeend', '<div class="loading-text" style="color:var(--yt-text-secondary); padding: 20px;">Loading videos...</div>');
}
try {
console.log(`Fetching: /api/channel/videos?id=${channelId}&page=${currentChannelPage}`);
const response = await fetch(`/api/channel/videos?id=${channelId}&page=${currentChannelPage}&sort=${currentChannelSort}&filter_type=${currentFilterType}`);
const videos = await response.json();
console.log("Channel Videos Response:", videos);
// Remove skeletons (simple way: remove last N children or just clear all if page 1?
// Better: mark skeletons with class and remove)
// For simplicity in this v1: We just clear skeletons by removing elements with 'skeleton-card' class
document.querySelectorAll('#channelVideosGrid .skeleton-card').forEach(el => el.remove());
// Check if response is an error
if (videos.error) {
hasMoreChannelVideos = false;
grid.innerHTML = `<p style="padding:20px; color:var(--yt-text-secondary);">Error: ${videos.error}</p>`;
return;
// Adjust Grid layout for Shorts vs Videos
const grid = document.getElementById('channelVideosGrid');
if (type === 'shorts') {
grid.style.gridTemplateColumns = 'repeat(auto-fill, minmax(200px, 1fr))';
} else {
grid.style.gridTemplateColumns = 'repeat(auto-fill, minmax(320px, 1fr))';
}
if (!Array.isArray(videos) || videos.length === 0) {
hasMoreChannelVideos = false;
if (currentChannelPage === 1) grid.innerHTML = '<p style="padding:20px; color:var(--yt-text-secondary);">No videos found.</p>';
fetchChannelContent();
}
function changeChannelSort(sort, btn) {
if (isChannelLoading) return;
currentChannelSort = sort;
currentChannelPage = 1;
hasMoreChannelVideos = true;
document.getElementById('channelVideosGrid').innerHTML = ''; // Clear
// Update tabs
document.querySelectorAll('.yt-sort-options a').forEach(a => a.classList.remove('active'));
btn.classList.add('active');
fetchChannelContent();
}
async function fetchChannelContent() {
console.log("fetchChannelContent() called");
if (isChannelLoading || !hasMoreChannelVideos) {
console.log("Early return:", { isChannelLoading, hasMoreChannelVideos });
return;
}
isChannelLoading = true;
const grid = document.getElementById('channelVideosGrid');
// Append Loading indicator
if (typeof renderSkeleton === 'function') {
grid.insertAdjacentHTML('beforeend', renderSkeleton(4));
} else {
// Update channel header with uploader info from first video (on first page only)
if (currentChannelPage === 1 && videos[0]) {
// Try multiple sources for channel name
let channelName = videos[0].uploader || videos[0].channel || '';
grid.insertAdjacentHTML('beforeend', '<div class="loading-text" style="color:var(--yt-text-secondary); padding: 20px;">Loading videos...</div>');
}
// If still empty, try to get from video title (sometimes includes " - ChannelName")
if (!channelName && videos[0].title) {
const parts = videos[0].title.split(' - ');
if (parts.length > 1) channelName = parts[parts.length - 1];
}
try {
console.log(`Fetching: /api/channel/videos?id=${channelId}&page=${currentChannelPage}`);
const response = await fetch(`/api/channel/videos?id=${channelId}&page=${currentChannelPage}&sort=${currentChannelSort}&filter_type=${currentFilterType}`);
const videos = await response.json();
console.log("Channel Videos Response:", videos);
// Final fallback: use channel ID
if (!channelName) channelName = channelId;
// Remove skeletons (simple way: remove last N children or just clear all if page 1?
// Better: mark skeletons with class and remove)
// For simplicity in this v1: We just clear skeletons by removing elements with 'skeleton-card' class
document.querySelectorAll('#channelVideosGrid .skeleton-card').forEach(el => el.remove());
document.getElementById('channelTitle').textContent = channelName;
document.getElementById('channelHandle').textContent = '@' + channelName.replace(/\s+/g, '');
const avatarLetter = document.getElementById('channelAvatarLetter');
if (avatarLetter) avatarLetter.textContent = channelName.charAt(0).toUpperCase();
// Update browser URL to show friendly name
const friendlyUrl = `/channel/@${encodeURIComponent(channelName.replace(/\s+/g, ''))}`;
window.history.replaceState({ channelId: channelId }, '', friendlyUrl);
// Check if response is an error
if (videos.error) {
hasMoreChannelVideos = false;
grid.innerHTML = `<p style="padding:20px; color:var(--yt-text-secondary);">Error: ${videos.error}</p>`;
return;
}
videos.forEach(video => {
const card = document.createElement('div');
if (!Array.isArray(videos) || videos.length === 0) {
hasMoreChannelVideos = false;
if (currentChannelPage === 1) grid.innerHTML = '<p style="padding:20px; color:var(--yt-text-secondary);">No videos found.</p>';
} else {
// Update channel header with uploader info from first video (on first page only)
if (currentChannelPage === 1 && videos[0]) {
// Use only proper channel/uploader fields - do NOT parse from title
let channelName = videos[0].channel || videos[0].uploader || '';
if (currentFilterType === 'shorts') {
// Render Vertical Short Card
card.className = 'yt-channel-short-card';
card.onclick = () => window.location.href = `/watch?v=${video.id}`;
card.innerHTML = `
// Only update header if API returned a meaningful name
// (not empty, not just the channel ID, and not "Loading...")
if (channelName && channelName !== channelId &&
!channelName.startsWith('UC') && channelName !== 'Loading...') {
document.getElementById('channelTitle').textContent = channelName;
document.getElementById('channelHandle').textContent = '@' + channelName.replace(/\s+/g, '');
const avatarLetter = document.getElementById('channelAvatarLetter');
if (avatarLetter) avatarLetter.textContent = channelName.charAt(0).toUpperCase();
}
// If no meaningful name from API, keep the initial template-rendered title
}
videos.forEach(video => {
const card = document.createElement('div');
if (currentFilterType === 'shorts') {
// Render Vertical Short Card
card.className = 'yt-channel-short-card';
card.onclick = () => window.location.href = `/watch?v=${video.id}`;
card.innerHTML = `
<div class="yt-short-thumb-container">
<img src="${video.thumbnail}" class="yt-short-thumb loaded" loading="lazy" onload="this.classList.add('loaded')">
</div>
@ -380,11 +398,11 @@
<p class="yt-video-stats">${formatViews(video.view_count)} views</p>
</div>
`;
} else {
// Render Standard Video Card (Match Home)
card.className = 'yt-video-card';
card.onclick = () => window.location.href = `/watch?v=${video.id}`;
card.innerHTML = `
} else {
// Render Standard Video Card (Match Home)
card.className = 'yt-video-card';
card.onclick = () => window.location.href = `/watch?v=${video.id}`;
card.innerHTML = `
<div class="yt-thumbnail-container">
<img class="yt-thumbnail loaded" src="${video.thumbnail}" loading="lazy" onload="this.classList.add('loaded')">
${video.duration ? `<span class="yt-duration">${video.duration}</span>` : ''}
@ -398,64 +416,69 @@
</div>
</div>
`;
}
grid.appendChild(card);
});
currentChannelPage++;
}
grid.appendChild(card);
});
currentChannelPage++;
}
} catch (e) {
console.error(e);
} finally {
isChannelLoading = false;
document.querySelectorAll('#channelVideosGrid .skeleton-card').forEach(el => el.remove());
}
} catch (e) {
console.error(e);
} finally {
isChannelLoading = false;
document.querySelectorAll('#channelVideosGrid .skeleton-card').forEach(el => el.remove());
}
}
function setupInfiniteScroll() {
const trigger = document.getElementById('channelLoadingTrigger');
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
fetchChannelContent();
function setupInfiniteScroll() {
const trigger = document.getElementById('channelLoadingTrigger');
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
fetchChannelContent();
}
}, { threshold: 0.1 });
observer.observe(trigger);
}
// Helpers - Define locally to ensure availability
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function formatViews(views) {
if (!views) return '0';
const num = parseInt(views);
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
if (num >= 1000) return (num / 1000).toFixed(0) + 'K';
return num.toLocaleString();
}
function formatDate(dateStr) {
if (!dateStr) return 'Recently';
try {
// Format: YYYYMMDD
const year = dateStr.substring(0, 4);
const month = dateStr.substring(4, 6);
const day = dateStr.substring(6, 8);
const date = new Date(year, month - 1, day);
const now = new Date();
const diff = now - date;
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days < 1) return 'Today';
if (days < 7) return `${days} days ago`;
if (days < 30) return `${Math.floor(days / 7)} weeks ago`;
if (days < 365) return `${Math.floor(days / 30)} months ago`;
return `${Math.floor(days / 365)} years ago`;
} catch (e) {
return 'Recently';
}
}, { threshold: 0.1 });
observer.observe(trigger);
}
// Helpers - Define locally to ensure availability
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function formatViews(views) {
if (!views) return '0';
const num = parseInt(views);
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
if (num >= 1000) return (num / 1000).toFixed(0) + 'K';
return num.toLocaleString();
}
function formatDate(dateStr) {
if (!dateStr) return 'Recently';
try {
// Format: YYYYMMDD
const year = dateStr.substring(0, 4);
const month = dateStr.substring(4, 6);
const day = dateStr.substring(6, 8);
const date = new Date(year, month - 1, day);
const now = new Date();
const diff = now - date;
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days < 1) return 'Today';
if (days < 7) return `${days} days ago`;
if (days < 30) return `${Math.floor(days / 7)} weeks ago`;
if (days < 365) return `${Math.floor(days / 30)} months ago`;
return `${Math.floor(days / 365)} years ago`;
} catch (e) {
return 'Recently';
}
}
// Expose functions globally for onclick handlers
window.changeChannelTab = changeChannelTab;
window.changeChannelSort = changeChannelSort;
})();
</script>
{% endblock %}

View file

@ -141,12 +141,6 @@
</head>
<body>
<!-- Global Progress Bar -->
<div id="nprogress-container"
style="position:fixed; top:0; left:0; width:100%; height:3px; z-index:9999; pointer-events:none;">
<div id="nprogress-bar" style="width:0%; height:100%; background:red; transition: width 0.2s ease; opacity: 0;">
</div>
</div>
<div class="app-wrapper">
<!-- YouTube-style Header -->
<header class="yt-header">

View file

@ -1,25 +1,200 @@
{% extends "layout.html" %}
{% block content %}
<div class="yt-container" style="padding-top: 20px;">
<div class="library-header"
style="margin-bottom: 3rem; display: flex; flex-direction: column; align-items: center; gap: 1.5rem;">
<h1 style="font-size: 2rem; font-weight: 700;">My Library</h1>
<div class="tabs"
style="display: flex; gap: 0.5rem; background: var(--yt-bg-secondary); padding: 0.4rem; border-radius: 100px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); align-items: center;">
<a href="/my-videos?type=history" class="yt-btn" id="tab-history"
style="border-radius: 100px; font-size: 0.95rem; padding: 0.6rem 2rem; font-weight:500; transition: all 0.2s;">History</a>
<a href="/my-videos?type=saved" class="yt-btn" id="tab-saved"
style="border-radius: 100px; font-size: 0.95rem; padding: 0.6rem 2rem; font-weight:500; transition: all 0.2s;">Saved</a>
<a href="/my-videos?type=subscriptions" class="yt-btn" id="tab-subscriptions"
style="border-radius: 100px; font-size: 0.95rem; padding: 0.6rem 2rem; font-weight:500; transition: all 0.2s;">Subscriptions</a>
<style>
/* Library Page Premium Styles */
.library-container {
padding: 24px;
max-width: 1600px;
margin: 0 auto;
}
.library-header {
margin-bottom: 2rem;
text-align: center;
}
.library-title {
font-size: 1.8rem;
font-weight: 700;
margin-bottom: 1.5rem;
background: linear-gradient(135deg, var(--yt-text-primary) 0%, var(--yt-text-secondary) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.library-tabs {
display: inline-flex;
gap: 8px;
background: var(--yt-bg-secondary);
padding: 6px;
border-radius: 16px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
margin-bottom: 1rem;
}
.library-tab {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 24px;
border-radius: 12px;
font-size: 0.95rem;
font-weight: 600;
color: var(--yt-text-secondary);
text-decoration: none;
transition: all 0.25s ease;
border: none;
background: transparent;
cursor: pointer;
}
.library-tab:hover {
color: var(--yt-text-primary);
background: var(--yt-bg-hover);
}
.library-tab.active {
background: linear-gradient(135deg, #cc0000 0%, #ff4444 100%);
color: white;
box-shadow: 0 4px 12px rgba(204, 0, 0, 0.3);
}
.library-tab i {
font-size: 1rem;
}
.library-actions {
display: flex;
justify-content: center;
gap: 12px;
margin-top: 16px;
}
.clear-btn {
display: none;
align-items: center;
gap: 8px;
padding: 10px 20px;
background: transparent;
border: 1px solid var(--yt-border);
border-radius: 24px;
color: var(--yt-text-secondary);
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.clear-btn:hover {
background: rgba(204, 0, 0, 0.1);
border-color: #cc0000;
color: #cc0000;
}
.library-stats {
display: flex;
justify-content: center;
gap: 24px;
margin-top: 12px;
color: var(--yt-text-secondary);
font-size: 0.85rem;
}
.library-stat {
display: flex;
align-items: center;
gap: 6px;
}
.library-stat i {
opacity: 0.7;
}
/* Empty State Enhancement */
.empty-state {
text-align: center;
padding: 4rem 2rem;
color: var(--yt-text-secondary);
}
.empty-state-icon {
width: 80px;
height: 80px;
margin: 0 auto 1.5rem;
border-radius: 50%;
background: var(--yt-bg-secondary);
display: flex;
align-items: center;
justify-content: center;
font-size: 2rem;
opacity: 0.6;
}
.empty-state h3 {
font-size: 1.3rem;
margin-bottom: 0.5rem;
color: var(--yt-text-primary);
}
.empty-state p {
margin-bottom: 1.5rem;
}
.browse-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 12px 24px;
background: linear-gradient(135deg, #cc0000 0%, #ff4444 100%);
color: white;
border: none;
border-radius: 24px;
font-weight: 600;
text-decoration: none;
transition: all 0.2s;
box-shadow: 0 4px 12px rgba(204, 0, 0, 0.3);
}
.browse-btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(204, 0, 0, 0.4);
}
</style>
<div class="library-container">
<div class="library-header">
<h1 class="library-title">My Library</h1>
<div class="library-tabs">
<a href="/my-videos?type=history" class="library-tab" id="tab-history">
<i class="fas fa-history"></i>
<span>History</span>
</a>
<a href="/my-videos?type=saved" class="library-tab" id="tab-saved">
<i class="fas fa-bookmark"></i>
<span>Saved</span>
</a>
<a href="/my-videos?type=subscriptions" class="library-tab" id="tab-subscriptions">
<i class="fas fa-users"></i>
<span>Subscriptions</span>
</a>
</div>
<!-- Clear Button (Hidden by default) -->
<button id="clearBtn" onclick="clearLibrary()" class="yt-btn"
style="display:none; color: var(--yt-text-secondary); background: transparent; border: 1px solid var(--yt-border); margin-top: 10px; font-size: 0.9rem;">
<i class="fas fa-trash-alt"></i> Clear <span id="clearType">All</span>
</button>
<div class="library-stats" id="libraryStats" style="display: none;">
<div class="library-stat">
<i class="fas fa-video"></i>
<span id="videoCount">0 videos</span>
</div>
</div>
<div class="library-actions">
<button id="clearBtn" onclick="clearLibrary()" class="clear-btn">
<i class="fas fa-trash-alt"></i>
<span>Clear <span id="clearType">All</span></span>
</button>
</div>
</div>
<!-- Video Grid -->
@ -28,32 +203,44 @@
</div>
<!-- Empty State -->
<div id="emptyState" style="text-align: center; padding: 4rem; color: var(--yt-text-secondary); display: none;">
<i class="fas fa-folder-open fa-3x" style="margin-bottom: 1rem; opacity: 0.5;"></i>
<div id="emptyState" class="empty-state" style="display: none;">
<div class="empty-state-icon">
<i class="fas fa-folder-open"></i>
</div>
<h3>Nothing here yet</h3>
<p id="emptyMsg">Go watch some videos to fill this up!</p>
<a href="/" class="yt-btn"
style="margin-top: 1rem; background: var(--yt-text-primary); color: var(--yt-bg-primary);">Browse
Content</a>
<a href="/" class="browse-btn">
<i class="fas fa-play"></i>
Browse Content
</a>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
// Load library content - extracted to function for reuse on pageshow
function loadLibraryContent() {
const urlParams = new URLSearchParams(window.location.search);
// Default to history if no type or invalid type
const type = urlParams.get('type') || 'history';
// Update Active Tab UI
// Reset all tabs first, then activate the correct one
document.querySelectorAll('.library-tab').forEach(tab => tab.classList.remove('active'));
const activeTab = document.getElementById(`tab-${type}`);
if (activeTab) {
activeTab.style.background = 'var(--yt-text-primary)';
activeTab.style.color = 'var(--yt-bg-primary)';
activeTab.classList.add('active');
}
const grid = document.getElementById('libraryGrid');
const empty = document.getElementById('emptyState');
const emptyMsg = document.getElementById('emptyMsg');
const statsDiv = document.getElementById('libraryStats');
const clearBtn = document.getElementById('clearBtn');
// Reset UI before loading
grid.innerHTML = '';
empty.style.display = 'none';
if (statsDiv) statsDiv.style.display = 'none';
if (clearBtn) clearBtn.style.display = 'none';
// Mapping URL type to localStorage key suffix
// saved -> kv_saved
@ -62,16 +249,24 @@
const storageKey = `kv_${type}`;
const data = JSON.parse(localStorage.getItem(storageKey) || '[]').filter(i => i && i.id);
// Show Clear Button if there is data
// Show stats and Clear Button if there is data
if (data.length > 0) {
empty.style.display = 'none';
const clearBtn = document.getElementById('clearBtn');
// Update stats
const videoCount = document.getElementById('videoCount');
if (statsDiv && videoCount) {
statsDiv.style.display = 'flex';
const countText = type === 'subscriptions'
? `${data.length} channel${data.length !== 1 ? 's' : ''}`
: `${data.length} video${data.length !== 1 ? 's' : ''}`;
videoCount.innerText = countText;
}
const clearTypeSpan = document.getElementById('clearType');
if (clearBtn) {
clearBtn.style.display = 'inline-flex';
clearBtn.style.alignItems = 'center';
clearBtn.style.gap = '8px';
// Format type name for display
const typeName = type.charAt(0).toUpperCase() + type.slice(1);
@ -155,6 +350,28 @@
emptyMsg.innerText = "No saved videos yet.";
}
}
}
// Run on initial page load
document.addEventListener('DOMContentLoaded', () => {
loadLibraryContent();
// Intercept tab clicks for client-side navigation
document.querySelectorAll('.library-tab').forEach(tab => {
tab.addEventListener('click', (e) => {
e.preventDefault();
const newUrl = tab.getAttribute('href');
// Update URL without reloading
history.pushState(null, '', newUrl);
// Immediately load the new content
loadLibraryContent();
});
});
});
// Handle browser back/forward buttons
window.addEventListener('popstate', () => {
loadLibraryContent();
});
function clearLibrary() {

View file

@ -67,9 +67,24 @@
Queue
<span id="queueBadge" class="queue-badge" style="display:none;">0</span>
</button>
<!-- Summarize button removed -->
<!-- Transcribe button removed -->
<!-- Rotation controls removed -->
<!-- View Mode Buttons -->
<div class="view-mode-buttons">
<button class="view-mode-btn" id="defaultModeBtn" onclick="setViewMode('default')"
title="Default View">
<i class="fas fa-columns"></i>
</button>
<button class="view-mode-btn active" id="theaterModeBtn" onclick="setViewMode('theater')"
title="Theater Mode">
<i class="fas fa-expand-alt"></i>
</button>
<button class="view-mode-btn" id="pipModeBtn" onclick="togglePiP()" title="Picture-in-Picture">
<i class="fas fa-external-link-square-alt"></i>
</button>
<button class="view-mode-btn" id="fullscreenBtn" onclick="toggleFullscreen()" title="Fullscreen">
<i class="fas fa-expand"></i>
</button>
</div>
</div>
@ -87,7 +102,7 @@
<p class="yt-video-stats" id="viewCount">0 views</p>
</div>
</div>
<button class="yt-subscribe-btn" id="subscribeBtn">Subscribe</button>
<button class="yt-subscribe-btn" id="subscribeBtn" onclick="toggleSubscribe()">Subscribe</button>
</div>
<!-- Description -->
@ -730,7 +745,8 @@
}
// --- Save to Library (Local Storage) ---
function saveToLibrary() {
// Note: Named toggleSaveToLibrary to avoid shadowing global saveToLibrary(type, item) in main.js
function toggleSaveToLibrary() {
const btn = document.getElementById('saveBtn');
if (!currentVideoData.id) {
showToast("Video data not ready", "error");
@ -788,6 +804,154 @@
}
}
// --- Subscribe Logic ---
function toggleSubscribe() {
const btn = document.getElementById('subscribeBtn');
// Get channel info from current video data
const channelName = document.getElementById('channelName')?.innerText || currentVideoData.uploader;
if (!channelName || channelName === 'Loading...') {
showToast("Channel info not ready yet", "error");
return;
}
// Try to get channel ID from the avatar link or construct from name
const avatarEl = document.getElementById('channelAvatar');
let channelId = avatarEl?.onclick ? avatarEl.getAttribute('data-channel-id') : null;
// If no channel ID stored, use channel name as ID (fallback)
if (!channelId) {
channelId = channelName.replace(/\s+/g, '');
}
let subscriptions = JSON.parse(localStorage.getItem('kv_subscriptions') || '[]');
const existingIndex = subscriptions.findIndex(s => s.id === channelId || s.title === channelName);
if (existingIndex !== -1) {
// Unsubscribe (toggle off)
subscriptions.splice(existingIndex, 1);
localStorage.setItem('kv_subscriptions', JSON.stringify(subscriptions));
showToast("Unsubscribed from " + channelName);
if (btn) {
btn.classList.remove('subscribed');
btn.style.background = '';
btn.style.color = '';
btn.innerHTML = 'Subscribe';
}
} else {
// Subscribe
const avatarLetter = document.getElementById('channelAvatarLetter')?.innerText || channelName.charAt(0).toUpperCase();
subscriptions.push({
id: channelId,
title: channelName,
thumbnail: null, // Could be fetched from API if available
letter: avatarLetter
});
localStorage.setItem('kv_subscriptions', JSON.stringify(subscriptions));
showToast("Subscribed to " + channelName, "success");
if (btn) {
btn.classList.add('subscribed');
btn.style.background = 'var(--yt-bg-secondary)';
btn.style.color = 'var(--yt-text-secondary)';
btn.innerHTML = '<i class="fas fa-bell"></i> Subscribed';
}
}
}
function updateSubscribeButtonState() {
const btn = document.getElementById('subscribeBtn');
if (!btn) return;
const channelName = document.getElementById('channelName')?.innerText;
if (!channelName || channelName === 'Loading...') return;
const subscriptions = JSON.parse(localStorage.getItem('kv_subscriptions') || '[]');
const isSubscribed = subscriptions.some(s => s.title === channelName);
if (isSubscribed) {
btn.classList.add('subscribed');
btn.style.background = 'var(--yt-bg-secondary)';
btn.style.color = 'var(--yt-text-secondary)';
btn.innerHTML = '<i class="fas fa-bell"></i> Subscribed';
} else {
btn.classList.remove('subscribed');
btn.style.background = '';
btn.style.color = '';
btn.innerHTML = 'Subscribe';
}
}
// --- View Mode Functions ---
function setViewMode(mode) {
const layout = document.querySelector('.yt-watch-layout');
const defaultBtn = document.getElementById('defaultModeBtn');
const theaterBtn = document.getElementById('theaterModeBtn');
if (mode === 'default') {
layout.classList.add('default-mode');
defaultBtn.classList.add('active');
theaterBtn.classList.remove('active');
} else {
layout.classList.remove('default-mode');
defaultBtn.classList.remove('active');
theaterBtn.classList.add('active');
}
// Save preference
localStorage.setItem('kv_view_mode', mode);
}
function togglePiP() {
const video = document.querySelector('video');
if (!video) {
showToast('Video not ready', 'error');
return;
}
if (document.pictureInPictureElement) {
document.exitPictureInPicture();
document.getElementById('pipModeBtn').classList.remove('active');
} else if (document.pictureInPictureEnabled) {
video.requestPictureInPicture();
document.getElementById('pipModeBtn').classList.add('active');
} else {
showToast('PiP not supported', 'info');
}
}
function toggleFullscreen() {
const container = document.querySelector('.yt-player-container');
const btn = document.getElementById('fullscreenBtn');
if (document.fullscreenElement) {
document.exitFullscreen();
btn.classList.remove('active');
} else {
container.requestFullscreen();
btn.classList.add('active');
}
}
// Initialize view mode from localStorage
document.addEventListener('DOMContentLoaded', () => {
const savedMode = localStorage.getItem('kv_view_mode') || 'theater';
setViewMode(savedMode);
// Listen for PiP exit
document.addEventListener('leavepictureinpicture', () => {
document.getElementById('pipModeBtn').classList.remove('active');
});
// Listen for fullscreen exit
document.addEventListener('fullscreenchange', () => {
if (!document.fullscreenElement) {
document.getElementById('fullscreenBtn').classList.remove('active');
}
});
});
// --- Download Video ---
async function downloadVideo() {
const videoId = "{{ video_id }}";
@ -1132,6 +1296,9 @@
const uploaderName = data.uploader || 'Unknown';
document.getElementById('channelAvatarLetter').innerText = uploaderName.charAt(0).toUpperCase();
// Update subscribe button state based on stored subscriptions
updateSubscribeButtonState();
// Save to History (Local & Server)
const historyItem = {
id: videoId,

269
wsgi.py
View file

@ -215,27 +215,66 @@ def get_history():
@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()
"""
Get suggested videos based on watch history.
Accepts both server-side DB history and client-side localStorage history.
Query params:
- titles: comma-separated list of watched video titles (from localStorage)
- channels: comma-separated list of watched channel names (from localStorage)
"""
import random
if not history:
# Get client-side history from query params
client_titles = request.args.get("titles", "")
client_channels = request.args.get("channels", "")
history_titles = []
history_channels = []
# Parse client-side history
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]
# Also get server-side history as 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:
pass
# If still no history, return trending
if not history_titles:
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))
# Build queries from history titles
queries = []
for title in history_titles[:3]:
# Extract key words from title (first 3-4 words usually capture the topic)
words = title.split()[:4]
query_base = " ".join(words)
queries.append(f"{query_base} related -shorts")
# Add channel-based queries
for channel in history_channels[:2]:
queries.append(f"{channel} latest videos -shorts")
# Fetch in parallel
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)
# Remove duplicates and shuffle
# Remove duplicates
unique_vids = {v["id"]: v for v in all_suggestions}.values()
import random
final_list = list(unique_vids)
random.shuffle(final_list)
@ -365,7 +404,11 @@ def channel(channel_id):
real_id_or_url = channel_id
is_search_fallback = False
if not channel_id.startswith("UC") and not channel_id.startswith("@"):
# 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:] # Remove the @ prefix
if not real_id_or_url.startswith("UC") and not real_id_or_url.startswith("@"):
# Simple resolve logic - reusing similar block from before but optimized for metadata
search_cmd = [
sys.executable,
@ -434,6 +477,24 @@ def channel(channel_id):
except:
pass
# If title is still just the ID, try to get channel name with --print
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:
pass
# Render shell - videos fetched via JS
return render_template("channel.html", channel=channel_info)
@ -625,47 +686,67 @@ def get_download_formats():
# Categorize by type
if f_ext == "mp4" or f_ext == "webm":
# Check if it's video or audio
if (
f.get("vcodec", "none") != "none"
and f.get("acodec", "none") == "none"
):
# Video only - include detailed specs
vcodec = f.get("vcodec", "none")
acodec = f.get("acodec", "none")
# Combined video+audio format (best for downloads with sound!)
if vcodec != "none" and acodec != "none":
width = f.get("width", 0)
height = f.get("height", 0)
resolution = f"{width}x{height}" if width and height else None
fps = f.get("fps", 0)
vbr = f.get("vbr", 0) or f.get("tbr", 0)
video_formats.append({
"quality": f"{quality} (with audio)",
"ext": f_ext,
"size": size_str,
"size_bytes": f_filesize,
"url": f_url,
"type": "combined", # Has both video and audio!
"resolution": resolution,
"width": width,
"height": height,
"fps": fps,
"vcodec": vcodec.split(".")[0],
"acodec": acodec.split(".")[0],
"bitrate": int(vbr) if vbr else None,
"has_audio": True,
})
# Video only - include detailed specs
elif vcodec != "none" and acodec == "none":
# Get resolution
width = f.get("width", 0)
height = f.get("height", 0)
resolution = f"{width}x{height}" if width and height else None
# Get codec (simplified name)
codec_display = vcodec.split(".")[0] if vcodec else ""
# Get fps and bitrate
fps = f.get("fps", 0)
vbr = f.get("vbr", 0) or f.get("tbr", 0)
if quality not in ["audio only", "unknown"]:
# Get resolution
width = f.get("width", 0)
height = f.get("height", 0)
resolution = f"{width}x{height}" if width and height else None
video_formats.append({
"quality": quality,
"ext": f_ext,
"size": size_str,
"size_bytes": f_filesize,
"url": f_url,
"type": "video",
"resolution": resolution,
"width": width,
"height": height,
"fps": fps,
"vcodec": codec_display,
"bitrate": int(vbr) if vbr else None,
"has_audio": False,
})
# Get codec (simplified name)
vcodec = f.get("vcodec", "")
codec_display = vcodec.split(".")[0] if vcodec else "" # e.g., "avc1" from "avc1.4d401f"
# Get fps and bitrate
fps = f.get("fps", 0)
vbr = f.get("vbr", 0) or f.get("tbr", 0) # video bitrate in kbps
video_formats.append(
{
"quality": quality,
"ext": f_ext,
"size": size_str,
"size_bytes": f_filesize,
"url": f_url,
"type": "video",
"resolution": resolution,
"width": width,
"height": height,
"fps": fps,
"vcodec": codec_display,
"bitrate": int(vbr) if vbr else None,
}
)
elif (
f.get("acodec", "none") != "none"
and f.get("vcodec", "none") == "none"
):
# Audio only - include detailed specs
# Audio only
elif acodec != "none" and vcodec == "none":
acodec = f.get("acodec", "")
codec_display = acodec.split(".")[0] if acodec else ""
@ -1562,22 +1643,37 @@ def trending():
# === 1. Suggested For You (History Based) ===
suggested_videos = []
try:
conn = get_db_connection()
# Get last 5 videos for context
history = conn.execute(
'SELECT title, video_id, type FROM user_videos WHERE type = "history" ORDER BY timestamp DESC LIMIT 5'
).fetchall()
conn.close()
import random
if history:
# Create a composite query from history
import random
# Get client-side history from query params (from localStorage)
client_titles = request.args.get("history_titles", "")
client_channels = request.args.get("history_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]
# Fallback to server-side history if no client history
if not history_titles:
try:
conn = get_db_connection()
rows = conn.execute(
'SELECT title, video_id FROM user_videos WHERE type = "history" ORDER BY timestamp DESC LIMIT 5'
).fetchall()
conn.close()
history_titles = [row["title"] for row in rows]
except:
pass
if history_titles:
# Pick 1-2 random items from recent history to diversify
bases = random.sample(history, min(len(history), 2))
query_parts = [row["title"] for row in bases]
# Add "related" to find similar content, not exact same
suggestion_query = " ".join(query_parts) + " related"
bases = random.sample(history_titles, min(len(history_titles), 2))
query_parts = [" ".join(title.split()[:4]) for title in bases] # First 4 words
suggestion_query = " ".join(query_parts) + " related -shorts"
suggested_videos = fetch_videos(
suggestion_query, limit=16, filter_type="video"
)
@ -1609,6 +1705,36 @@ def trending():
except:
pass
# === 3. More From Your Channels (Same-Channel Recommendations) ===
channel_videos = []
channel_name = "Channels"
try:
conn = get_db_connection()
# Get unique channels from recent history
channels = conn.execute(
'''SELECT DISTINCT channel_id, uploader FROM user_videos
WHERE type = "history" AND channel_id IS NOT NULL AND channel_id != ""
ORDER BY timestamp DESC LIMIT 3'''
).fetchall()
conn.close()
if channels:
# Pick a random channel from recent history
import random
selected_channel = random.choice(channels)
channel_id = selected_channel["channel_id"]
channel_name = selected_channel["uploader"] or "Channel"
# Fetch videos from this channel
if channel_id:
channel_videos = fetch_videos(
f"channel:{channel_id}",
limit=8,
filter_type="video"
)
except Exception as e:
print(f"Channel recommendation error: {e}")
# === New Progressive Loading Strategy ===
feed_type = request.args.get('feed_type', 'all') # 'primary', 'secondary', or 'all'
final_sections = []
@ -1633,7 +1759,16 @@ def trending():
"videos": discovery_videos[:8], # Limit to 8
})
# 3. Trending (Standard)
# 3. More From Your Channels (Same-Channel Recommendations)
if channel_videos:
final_sections.append({
"id": "channel_rec",
"title": f"More from {channel_name}",
"icon": "user-circle",
"videos": channel_videos[:8],
})
# 4. Trending (Standard)
# Limit reduced to 8 (2 rows) for speed
trending_videos = fetch_videos(get_query("trending", region, "relevance"), limit=8, filter_type="video")
if trending_videos: