Compare commits
3 commits
38449d23d6
...
8aef1a79d4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8aef1a79d4 | ||
|
|
3cf599bd2d | ||
|
|
60d3bc3a5e |
13 changed files with 1110 additions and 379 deletions
12
.dockerignore
Normal file
12
.dockerignore
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
__pycache__
|
||||
.venv
|
||||
.git
|
||||
.env
|
||||
*.mp4
|
||||
*.webm
|
||||
*.mp3
|
||||
videos/
|
||||
data/
|
||||
temp/
|
||||
deployment_package/
|
||||
kvtube.db
|
||||
124
README.md
124
README.md
|
|
@ -1,45 +1,52 @@
|
|||
#
|
||||
**KV-Tube** is a distraction-free, privacy-focused YouTube frontend designed for a premium viewing experience.
|
||||
# KV-Tube
|
||||
**A Distraction-Free, Privacy-Focused YouTube Client**
|
||||
|
||||
### 🚀 **New Features (v2.0 Updates)**
|
||||
* **Horizontal-First Experience**: Strictly enforces horizontal videos across all categories. "Shorts" and vertical content are aggressively filtered out for a cleaner, cinematic feed.
|
||||
* **Personalized Discovery**:
|
||||
* **Suggested for You**: Dynamic recommendations based on your local watch history.
|
||||
* **You Might Like**: curated discovery topics to help you find new interests.
|
||||
* **Refined Tech Feed**: Specialized "Tech & AI" section focusing on gadget reviews, unboxings, and deep dives (no spammy vertical clips).
|
||||
* **Performance**: Optimized fetching limits to ensure rich, full grids of content despite strict filtering.
|
||||
> [!NOTE]
|
||||
> Designed for a premium, cinematic viewing experience.
|
||||
|
||||
## Features
|
||||
* **No Ads**: Watch videos without interruptions.
|
||||
* **Privacy Focused**: No Google account required. Watch history is stored locally (managed by SQLite).
|
||||
- **Trending**: Browse trending videos by category (Tech, Music, Gaming, etc.).
|
||||
- **Auto-Captions**: English subtitles automatically enabled if available.
|
||||
- **AI Summary**: (Optional) Extractive summarization of video content running locally.
|
||||
- **PWA Ready**: Installable on mobile devices with a responsive drawer layout.
|
||||
- **Dark/Light Mode**: User preference persisted in settings.
|
||||
KV-Tube removes the clutter and noise of modern YouTube, focusing purely on the content you love. It strictly enforces a horizontal-first video policy, aggressively filtering out Shorts and vertical "TikTok-style" content to keep your feed clean and high-quality.
|
||||
|
||||
## 🚀 Deployment
|
||||
### 🚀 **Key Features (v2.0)**
|
||||
|
||||
### Option A: Docker Compose (Recommended for Synology NAS)
|
||||
* **🚫 Ads-Free & Privacy-First**: Watch without interruptions. No Google account required. All watch history is stored locally on your device (or self-hosted DB).
|
||||
* **📺 Horizontal-First Experience**: Say goodbye to "Shorts". The feed only displays horizontal, cinematic content.
|
||||
* **🔍 Specialized Feeds**:
|
||||
* **Tech & AI**: Clean feed for gadget reviews and deep dives.
|
||||
* **Trending**: See what's popular across major categories (Music, Gaming, News).
|
||||
* **Suggested for You**: Personalized recommendations based on your local watch history.
|
||||
* **🧠 Local AI Integration**:
|
||||
* **Auto-Captions**: Automatically enables English subtitles.
|
||||
* **AI Summary**: (Optional) Generate quick text summaries of videos locally.
|
||||
* **⚡ High Performance**: Optimized for speed with smart caching and rate-limit handling.
|
||||
* **📱 PWA Ready**: Install on your phone or tablet with a responsive, app-like interface.
|
||||
|
||||
This is the easiest way to run KV-Tube.
|
||||
---
|
||||
|
||||
1. Create a folder named `kv-tube` on your NAS/Server.
|
||||
2. Copy `docker-compose.yml` into that folder.
|
||||
3. Create a `data` folder inside `kv-tube`.
|
||||
4. Run the container.
|
||||
## 🛠️ Deployment
|
||||
|
||||
**docker-compose.yml**
|
||||
You can run KV-Tube easily using Docker (recommended for NAS/Servers) or directly with Python.
|
||||
|
||||
### Option A: Docker Compose (Recommended)
|
||||
Ideal for Synology NAS, Unraid, or casual servers.
|
||||
|
||||
1. Create a folder `kv-tube` and add the `docker-compose.yml` file.
|
||||
2. Run the container:
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
3. Access the app at: **http://localhost:5011**
|
||||
|
||||
**docker-compose.yml**:
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
kv-tube:
|
||||
image: vndangkhoa/kvtube:latest
|
||||
image: vndangkhoa/kv-tube:latest
|
||||
container_name: kv-tube
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "5011:5001"
|
||||
- "5011:5000"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
environment:
|
||||
|
|
@ -47,38 +54,57 @@ services:
|
|||
- FLASK_ENV=production
|
||||
```
|
||||
|
||||
**Run Command:**
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
Access the app at `http://YOUR_NAS_IP:5011`
|
||||
|
||||
### Option B: Local Development (Python)
|
||||
For developers or running locally on a PC.
|
||||
|
||||
1. **Clone the repository:**
|
||||
1. **Clone & Install**:
|
||||
```bash
|
||||
git clone https://github.com/vndangkhoa/kv-tube.git
|
||||
cd kv-tube
|
||||
```
|
||||
|
||||
2. **Install Dependencies:**
|
||||
```bash
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
python -m venv .venv
|
||||
# Windows
|
||||
.venv\Scripts\activate
|
||||
# Linux/Mac
|
||||
source .venv/bin/activate
|
||||
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
3. **Run:**
|
||||
2. **Run**:
|
||||
```bash
|
||||
python3 app.py
|
||||
python wsgi.py
|
||||
```
|
||||
Open `http://127.0.0.1:5001` in your browser.
|
||||
|
||||
## 🛠️ Configuration
|
||||
3. Access the app at: **http://localhost:5002**
|
||||
|
||||
The app is zero-config by default.
|
||||
- **Database**: SQLite (stored in `./data/kvtube.db`)
|
||||
- **Port**: 5001 (internal), mapped to 5011 in Docker compose example.
|
||||
---
|
||||
|
||||
## 📝 License
|
||||
Proprietary / Personal Use.
|
||||
## ⚙️ Configuration
|
||||
|
||||
KV-Tube is designed to be "Zero-Config", but you can customize it via Environment Variables (in `.env` or Docker).
|
||||
|
||||
| Variable | Default | Description |
|
||||
| :--- | :--- | :--- |
|
||||
| `FLASK_ENV` | `production` | Set to `development` for debug mode. |
|
||||
| `KVTUBE_DATA_DIR` | `./data` | Location for the SQLite database. |
|
||||
| `KVTUBE_VIDEO_DIR` | `./videos` | (Optional) Location for downloaded videos. |
|
||||
| `SECRET_KEY` | *(Auto)* | Session security key. Set manually for persistence. |
|
||||
|
||||
---
|
||||
|
||||
## 🔌 API Endpoints
|
||||
KV-Tube exposes a RESTful API for its frontend.
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
| :--- | :--- | :--- |
|
||||
| `/api/search` | `GET` | Search for videos. |
|
||||
| `/api/stream_info` | `GET` | Get raw stream URLs (HLS/MP4). |
|
||||
| `/api/suggested` | `GET` | Get recommendations based on history. |
|
||||
| `/api/download` | `GET` | Get direct download link for a video. |
|
||||
| `/api/history` | `GET` | Retrieve local watch history. |
|
||||
|
||||
---
|
||||
|
||||
## 📜 License
|
||||
Proprietary / Personal Use.
|
||||
Created by **Khoa N.D**
|
||||
|
|
|
|||
27
deploy_v2.ps1
Normal file
27
deploy_v2.ps1
Normal 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
|
||||
|
|
@ -6,7 +6,7 @@ version: '3.8'
|
|||
services:
|
||||
kv-tube:
|
||||
# build: .
|
||||
image: vndangkhoa/kv-tube:v2.1
|
||||
image: vndangkhoa/kv-tube:latest
|
||||
container_name: kv-tube
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}®ion=${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}®ion=${regionValue}${historyParams}${cb}`);
|
||||
const data = await response.json();
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
273
wsgi.py
273
wsgi.py
|
|
@ -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
|
||||
|
||||
# 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 not history:
|
||||
# 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)
|
||||
|
||||
|
|
@ -364,8 +403,12 @@ def channel(channel_id):
|
|||
# Robustness: Resolve name to ID if needed (Metadata only fetch)
|
||||
real_id_or_url = channel_id
|
||||
is_search_fallback = False
|
||||
|
||||
# If channel_id is @UCN... format, strip the @ to get the proper UC ID
|
||||
if channel_id.startswith("@UC"):
|
||||
real_id_or_url = channel_id[1:] # Remove the @ prefix
|
||||
|
||||
if not channel_id.startswith("UC") and not channel_id.startswith("@"):
|
||||
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,
|
||||
|
|
@ -433,6 +476,24 @@ def channel(channel_id):
|
|||
# Try to get avatar/banner if available in flat dump (often NOT, but title/id are key)
|
||||
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
|
||||
|
||||
# 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
|
||||
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,
|
||||
})
|
||||
|
||||
# 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()
|
||||
|
||||
if history:
|
||||
# Create a composite query from history
|
||||
import random
|
||||
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:
|
||||
|
|
|
|||
Loading…
Reference in a new issue