Compare commits

...

5 commits

Author SHA1 Message Date
KV-Tube Deployer
b7ea9165a1 Update README with single container deployment instructions
Some checks failed
CI / lint (push) Failing after 5s
CI / test (push) Failing after 1s
CI / build (push) Has been skipped
2026-02-22 21:30:47 +07:00
KV-Tube Deployer
21df1d1b8c Fix backend port collision in Single Container Deployment 2026-02-22 21:24:08 +07:00
KV-Tube Deployer
ddb64e2ce3 Deploy Single Container architecture with Supervisord 2026-02-22 21:12:51 +07:00
KV-Tube Deployer
4c5bccbd61 Bump deployment versions to v4.0.1 2026-02-22 21:06:46 +07:00
KV-Tube Deployer
c49d827296 Fix audio playback, sidebar overlap, and UI highlights 2026-02-22 21:04:48 +07:00
16 changed files with 468 additions and 132 deletions

61
Dockerfile Normal file
View file

@ -0,0 +1,61 @@
# ---- Backend Builder ----
FROM golang:1.24-alpine AS backend-builder
WORKDIR /app
RUN apk add --no-cache git gcc musl-dev
COPY backend/go.mod backend/go.sum ./
RUN go mod download
COPY backend/ ./
RUN CGO_ENABLED=1 GOOS=linux go build -o kv-tube .
# ---- Frontend Builder ----
FROM node:20-alpine AS frontend-deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
FROM node:20-alpine AS frontend-builder
WORKDIR /app
COPY --from=frontend-deps /app/node_modules ./node_modules
COPY frontend/ ./
ENV NEXT_TELEMETRY_DISABLED 1
RUN npm run build
# ---- Final Unified Image ----
FROM alpine:latest
# Install dependencies for Go backend, Node.js frontend, and Supervisord
RUN apk add --no-cache \
nodejs \
ca-certificates \
ffmpeg \
curl \
python3 \
py3-pip \
supervisor \
&& curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp \
&& chmod a+rx /usr/local/bin/yt-dlp
WORKDIR /app
# Copy Backend Binary
COPY --from=backend-builder /app/kv-tube /app/kv-tube
# Copy Frontend Standalone App
COPY --from=frontend-builder /app/public /app/frontend/public
COPY --from=frontend-builder /app/.next/standalone /app/frontend/
COPY --from=frontend-builder /app/.next/static /app/frontend/.next/static
# Copy Supervisord Config
COPY supervisord.conf /etc/supervisord.conf
# Setup Environment
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV KVTUBE_DATA_DIR=/app/data
ENV GIN_MODE=release
ENV NEXT_PUBLIC_API_URL=http://127.0.0.1:8080
EXPOSE 3000 8080
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"]

View file

@ -5,6 +5,7 @@ A modern, fast, and fully-featured YouTube-like video streaming platform. Built
## Features
- **Modern Video Player**: High-resolution video playback with HLS support and quality selection.
- **Fast Navigation**: Instant click feedback with skeleton loaders for related videos.
- **Infinite Scrolling**: Scroll seamlessly through a dynamic video grid on the homepage.
- **Watch History & Suggestions**: Keep track of what you've watched, with smart video suggestions.
- **Region Selection**: Tailor your content to specific regions (e.g., Vietnam).
@ -12,8 +13,9 @@ A modern, fast, and fully-featured YouTube-like video streaming platform. Built
- **Containerized**: Fully Dockerized for easy setup using `docker-compose`.
## Architecture
- **Backend**: Go (Gin framework), SQLite for watch history, optimized for `linux/amd64`.
- **Frontend**: Next.js utilizing React Server Components and standalone output for minimal container footprint.
- **Backend & Frontend**: Go (Gin framework) and Next.js are combined into a single unified Docker container using a multi-stage `Dockerfile`.
- **Process Management**: `supervisord` manages the concurrent execution of the backend API and Next.js frontend within the same network namespace.
- **Data storage**: SQLite is used for watch history, optimized for `linux/amd64`.
## Deployment on Synology NAS
@ -31,30 +33,18 @@ Create a `docker-compose.yml` file matching the one provided in the repository:
version: '3.8'
services:
kv-tube-backend:
image: git.khoavo.myds.me/vndangkhoa/kv-tube-backend:v4.0.0
container_name: kv-tube-backend
kv-tube-app:
image: git.khoavo.myds.me/vndangkhoa/kv-tube-app:v4.0.1
container_name: kv-tube-app
restart: unless-stopped
ports:
- "5011:3000"
volumes:
- ./data:/app/data
environment:
- KVTUBE_DATA_DIR=/app/data
- GIN_MODE=release
healthcheck:
test: [ "CMD", "curl", "-f", "http://localhost:8080/api/health" ]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
kv-tube-frontend:
image: git.khoavo.myds.me/vndangkhoa/kv-tube-frontend:v4.0.0
container_name: kv-tube-frontend
restart: unless-stopped
ports:
- "5011:3000"
depends_on:
- kv-tube-backend
- NODE_ENV=production
```
### 3. Run

View file

@ -108,16 +108,13 @@ func handleGetStreamInfo(c *gin.Context) {
return
}
info, err := services.GetVideoInfo(videoID)
info, qualities, audioURL, err := services.GetFullStreamData(videoID)
if err != nil {
log.Printf("GetVideoInfo Error: %v", err)
log.Printf("GetFullStreamData Error: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get video info"})
return
}
// Get available qualities with audio
qualities, audioURL, _ := services.GetVideoQualitiesWithAudio(videoID)
// Build quality options for frontend
var qualityOptions []gin.H
bestURL := info.StreamURL

View file

@ -461,6 +461,201 @@ func GetVideoQualitiesWithAudio(videoID string) ([]QualityFormat, string, error)
return qualities, audioURL, nil
}
// GetFullStreamData runs a single yt-dlp command to fetch all essential information at once
// This avoids doing 3 separate slow calls for video info, qualities, and best audio.
func GetFullStreamData(videoID string) (*VideoData, []QualityFormat, string, error) {
urlStr := fmt.Sprintf("https://www.youtube.com/watch?v=%s", videoID)
cmdArgs := []string{
"--dump-json",
"--no-warnings",
"--quiet",
"--force-ipv4",
"--no-playlist",
"--user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
urlStr,
}
binPath := "yt-dlp"
if _, err := exec.LookPath("yt-dlp"); err != nil {
fallbacks := []string{
os.ExpandEnv("$HOME/Library/Python/3.14/bin/yt-dlp"),
os.ExpandEnv("$HOME/Library/Python/3.13/bin/yt-dlp"),
os.ExpandEnv("$HOME/Library/Python/3.12/bin/yt-dlp"),
os.ExpandEnv("$HOME/.local/bin/yt-dlp"),
"/usr/local/bin/yt-dlp",
"/opt/homebrew/bin/yt-dlp",
"/config/.local/bin/yt-dlp",
}
for _, fb := range fallbacks {
if _, err := os.Stat(fb); err == nil {
binPath = fb
break
}
}
}
cmd := exec.Command(binPath, cmdArgs...)
var out bytes.Buffer
var stderr bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
log.Printf("yt-dlp error in GetFullStreamData: %v, stderr: %s", err, stderr.String())
return nil, nil, "", err
}
// Unmarshal common metadata
var entry YtDlpEntry
if err := json.Unmarshal(out.Bytes(), &entry); err != nil {
return nil, nil, "", err
}
videoData := sanitizeVideoData(entry)
videoData.StreamURL = entry.URL
// Unmarshal formats specifically
var raw struct {
Formats []struct {
FormatID string `json:"format_id"`
FormatNote string `json:"format_note"`
Ext string `json:"ext"`
Resolution string `json:"resolution"`
Width interface{} `json:"width"`
Height interface{} `json:"height"`
URL string `json:"url"`
ManifestURL string `json:"manifest_url"`
VCodec string `json:"vcodec"`
ACodec string `json:"acodec"`
Filesize interface{} `json:"filesize"`
ABR interface{} `json:"abr"`
} `json:"formats"`
}
if err := json.Unmarshal(out.Bytes(), &raw); err != nil {
return nil, nil, "", err
}
var qualities []QualityFormat
seen := make(map[int]int) // height -> index in qualities
var bestAudio string
var bestABR float64
for _, f := range raw.Formats {
// Determine if it's the best audio
if f.VCodec == "none" && f.ACodec != "none" && f.URL != "" {
var abr float64
switch v := f.ABR.(type) {
case float64:
abr = v
case int:
abr = float64(v)
}
if bestAudio == "" || abr > bestABR {
bestABR = abr
bestAudio = f.URL
}
}
if f.VCodec == "none" || f.URL == "" {
continue
}
var height int
switch v := f.Height.(type) {
case float64:
height = int(v)
case int:
height = v
}
if height == 0 {
continue
}
hasAudio := f.ACodec != "none" && f.ACodec != ""
var filesize int64
switch v := f.Filesize.(type) {
case float64:
filesize = int64(v)
case int64:
filesize = v
}
isHLS := f.ManifestURL != "" || strings.Contains(f.URL, ".m3u8") || strings.Contains(f.URL, "manifest")
label := f.FormatNote
if label == "" {
switch height {
case 2160:
label = "4K"
case 1440:
label = "1440p"
case 1080:
label = "1080p"
case 720:
label = "720p"
case 480:
label = "480p"
case 360:
label = "360p"
default:
label = fmt.Sprintf("%dp", height)
}
}
streamURL := f.URL
if f.ManifestURL != "" {
streamURL = f.ManifestURL
}
qf := QualityFormat{
FormatID: f.FormatID,
Label: label,
Resolution: f.Resolution,
Height: height,
URL: streamURL,
IsHLS: isHLS,
VCodec: f.VCodec,
ACodec: f.ACodec,
Filesize: filesize,
HasAudio: hasAudio,
}
// Prefer formats with audio, otherwise just add
if idx, exists := seen[height]; exists {
// Replace if this one has audio and the existing one doesn't
if hasAudio && !qualities[idx].HasAudio {
qualities[idx] = qf
}
} else {
seen[height] = len(qualities)
qualities = append(qualities, qf)
}
}
// Sort by height descending
for i := range qualities {
for j := i + 1; j < len(qualities); j++ {
if qualities[j].Height > qualities[i].Height {
qualities[i], qualities[j] = qualities[j], qualities[i]
}
}
}
// Attach audio URL to qualities without audio
for i := range qualities {
if !qualities[i].HasAudio && bestAudio != "" {
qualities[i].AudioURL = bestAudio
}
}
return &videoData, qualities, bestAudio, nil
}
func GetStreamURLForQuality(videoID string, height int) (string, error) {
qualities, err := GetVideoQualities(videoID)
if err != nil {

View file

@ -4,33 +4,18 @@
version: '3.8'
services:
kv-tube-backend:
image: git.khoavo.myds.me/vndangkhoa/kv-tube-backend:v4.0.0
container_name: kv-tube-backend
kv-tube-app:
image: git.khoavo.myds.me/vndangkhoa/kv-tube-app:v4.0.1
container_name: kv-tube-app
platform: linux/amd64
restart: unless-stopped
ports:
- "5011:3000"
volumes:
- ./data:/app/data
environment:
- KVTUBE_DATA_DIR=/app/data
- GIN_MODE=release
healthcheck:
test: [ "CMD", "curl", "-f", "http://localhost:8080/api/health" ]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
labels:
- "com.centurylinklabs.watchtower.enable=true"
kv-tube-frontend:
image: git.khoavo.myds.me/vndangkhoa/kv-tube-frontend:v4.0.0
container_name: kv-tube-frontend
restart: unless-stopped
ports:
- "5011:3000"
environment:
- NEXT_PUBLIC_API_URL=http://kv-tube-backend:8080
depends_on:
- kv-tube-backend
- NODE_ENV=production
labels:
- "com.centurylinklabs.watchtower.enable=true"

View file

@ -1,5 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8080';
export async function GET(request: NextRequest) {

View file

@ -1,5 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
export async function GET(request: NextRequest) {
const videoId = request.nextUrl.searchParams.get('v');

View file

@ -30,15 +30,15 @@ export default function Sidebar() {
justifyContent: 'center',
padding: '16px 0 14px 0',
borderRadius: '10px',
backgroundColor: isActive ? 'var(--yt-hover)' : 'transparent',
backgroundColor: 'transparent',
marginBottom: '4px',
transition: 'var(--yt-transition)',
gap: '4px',
position: 'relative',
width: '100%'
}}
className="yt-sidebar-item"
>
{isActive && <div className="sidebar-active-indicator" />}
<div style={{ color: 'var(--yt-text-primary)', transition: 'transform 0.15s ease' }}>
{item.icon}
</div>

View file

@ -1,4 +1,7 @@
'use client';
import Link from 'next/link';
import { useState } from 'react';
interface VideoData {
id: string;
@ -25,10 +28,15 @@ function getRelativeTime(id: string): string {
export default function VideoCard({ video, hideChannelAvatar }: { video: VideoData; hideChannelAvatar?: boolean }) {
const relativeTime = video.uploaded_date || getRelativeTime(video.id);
const [isNavigating, setIsNavigating] = useState(false);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px', width: '100%', marginBottom: '12px' }} className="videocard-container">
<Link href={`/watch?v=${video.id}`} style={{ position: 'relative', display: 'block', width: '100%', aspectRatio: '16/9', overflow: 'hidden', borderRadius: '12px' }}>
<Link
href={`/watch?v=${video.id}`}
onClick={() => setIsNavigating(true)}
style={{ position: 'relative', display: 'block', width: '100%', aspectRatio: '16/9', overflow: 'hidden', borderRadius: '12px' }}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={video.thumbnail}
@ -41,6 +49,23 @@ export default function VideoCard({ video, hideChannelAvatar }: { video: VideoDa
{video.duration}
</div>
)}
{isNavigating && (
<div style={{
position: 'absolute', top: 0, left: 0, right: 0, bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 10
}}>
<div style={{
width: '40px', height: '40px',
border: '3px solid rgba(255,255,255,0.3)',
borderTopColor: '#fff',
borderRadius: '50%',
animation: 'spin 1s linear infinite'
}} />
</div>
)}
</Link>
<div style={{ display: 'flex', gap: '12px', padding: '0 12px' }} className="videocard-info">

View file

@ -251,7 +251,7 @@
align-items: center;
justify-content: center;
padding: 12px 4px;
z-index: 400;
z-index: 600;
gap: 4px;
}
@ -1384,4 +1384,14 @@ a {
grid-template-columns: 1fr;
gap: 0;
}
}@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}

View file

@ -0,0 +1,14 @@
'use client';
import { useEffect } from 'react';
export default function NextVideoClient({ videoId }: { videoId: string }) {
useEffect(() => {
if (typeof window !== 'undefined' && videoId) {
const event = new CustomEvent('setNextVideoId', { detail: { videoId } });
window.dispatchEvent(event);
}
}, [videoId]);
return null;
}

View file

@ -0,0 +1,69 @@
import Link from 'next/link';
import { API_BASE } from '../constants';
import NextVideoClient from './NextVideoClient';
interface VideoData {
id: string;
title: string;
uploader: string;
channel_id?: string;
thumbnail: string;
view_count: number;
duration: string;
}
async function getRelatedVideos(videoId: string, title: string, uploader: string) {
try {
const params = new URLSearchParams({ v: videoId, title: title || '', uploader: uploader || '', limit: '15' });
const res = await fetch(`${API_BASE}/api/related?${params.toString()}`, { cache: 'no-store' });
if (!res.ok) return [];
return res.json() as Promise<VideoData[]>;
} catch (e) {
console.error(e);
return [];
}
}
function formatViews(views: number): string {
if (views >= 1000000) return (views / 1000000).toFixed(1) + 'M';
if (views >= 1000) return (views / 1000).toFixed(1) + 'K';
return views.toString();
}
export default async function RelatedVideos({ videoId, title, uploader }: { videoId: string, title: string, uploader: string }) {
const relatedVideos = await getRelatedVideos(videoId, title, uploader);
if (relatedVideos.length === 0) {
return <div style={{ padding: '1rem', color: '#888' }}>No related videos found.</div>;
}
const nextVideoId = relatedVideos[0].id;
return (
<div className="watch-related-list">
<NextVideoClient videoId={nextVideoId} />
{relatedVideos.map((video, i) => {
const views = formatViews(video.view_count);
const staggerClass = `stagger-${Math.min(i + 1, 6)}`;
return (
<Link key={video.id} href={`/watch?v=${video.id}`} className={`related-video-item fade-in-up ${staggerClass}`} style={{ opacity: 0 }}>
<div className="related-thumb-container">
<img src={video.thumbnail} alt={video.title} className="related-thumb-img" />
{video.duration && (
<div className="duration-badge">
{video.duration}
</div>
)}
</div>
<div className="related-video-info">
<span className="related-video-title">{video.title}</span>
<span className="related-video-channel">{video.uploader}</span>
<span className="related-video-meta">{views} views</span>
</div>
</Link>
);
})}
</div>
);
}

View file

@ -12,7 +12,6 @@ declare global {
interface VideoPlayerProps {
videoId: string;
title?: string;
nextVideoId?: string;
}
interface QualityOption {
@ -56,7 +55,7 @@ function PlayerSkeleton() {
);
}
export default function VideoPlayer({ videoId, title, nextVideoId }: VideoPlayerProps) {
export default function VideoPlayer({ videoId, title }: VideoPlayerProps) {
const router = useRouter();
const videoRef = useRef<HTMLVideoElement>(null);
const audioRef = useRef<HTMLAudioElement>(null);
@ -71,8 +70,19 @@ export default function VideoPlayer({ videoId, title, nextVideoId }: VideoPlayer
const [hasSeparateAudio, setHasSeparateAudio] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [isBuffering, setIsBuffering] = useState(false);
const [nextVideoId, setNextVideoId] = useState<string | undefined>();
const audioUrlRef = useRef<string>('');
useEffect(() => {
const handleSetNextVideo = (e: CustomEvent) => {
if (e.detail && e.detail.videoId) {
setNextVideoId(e.detail.videoId);
}
};
window.addEventListener('setNextVideoId', handleSetNextVideo as EventListener);
return () => window.removeEventListener('setNextVideoId', handleSetNextVideo as EventListener);
}, []);
useEffect(() => {
const script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/hls.js@latest';
@ -94,7 +104,7 @@ export default function VideoPlayer({ videoId, title, nextVideoId }: VideoPlayer
if (video.paused && !audio.paused) {
audio.pause();
} else if (!video.paused && audio.paused) {
audio.play().catch(() => {});
audio.play().catch(() => { });
}
};
@ -195,19 +205,19 @@ export default function VideoPlayer({ videoId, title, nextVideoId }: VideoPlayer
if (isHLS && window.Hls && window.Hls.isSupported()) {
if (hlsRef.current) hlsRef.current.destroy();
const hls = new window.Hls({
xhrSetup: (xhr: XMLHttpRequest) => {
xhr.setRequestHeader('Referer', 'https://www.youtube.com/');
},
});
hlsRef.current = hls;
hls.loadSource(streamUrl);
hls.attachMedia(video);
hls.on(window.Hls.Events.MANIFEST_PARSED, () => {
video.play().catch(() => {});
video.play().catch(() => { });
});
hls.on(window.Hls.Events.ERROR, (_: any, data: any) => {
@ -219,27 +229,27 @@ export default function VideoPlayer({ videoId, title, nextVideoId }: VideoPlayer
});
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = streamUrl;
video.onloadedmetadata = () => video.play().catch(() => {});
video.onloadedmetadata = () => video.play().catch(() => { });
} else {
video.src = streamUrl;
video.onloadeddata = () => video.play().catch(() => {});
video.onloadeddata = () => video.play().catch(() => { });
}
if (needsSeparateAudio) {
const audio = audioRef.current;
if (audio) {
const audioIsHLS = audioStreamUrl!.includes('.m3u8') || audioStreamUrl!.includes('manifest');
if (audioIsHLS && window.Hls && window.Hls.isSupported()) {
if (audioHlsRef.current) audioHlsRef.current.destroy();
const audioHls = new window.Hls({
xhrSetup: (xhr: XMLHttpRequest) => {
xhr.setRequestHeader('Referer', 'https://www.youtube.com/');
},
});
audioHlsRef.current = audioHls;
audioHls.loadSource(audioStreamUrl!);
audioHls.attachMedia(audio);
} else {
@ -268,12 +278,12 @@ export default function VideoPlayer({ videoId, title, nextVideoId }: VideoPlayer
setCurrentQuality(quality.height);
video.currentTime = currentTime;
if (wasPlaying) video.play().catch(() => {});
if (wasPlaying) video.play().catch(() => { });
};
useEffect(() => {
if (!useFallback) return;
const handleMessage = (event: MessageEvent) => {
if (event.origin !== 'https://www.youtube.com') return;
try {
@ -281,7 +291,7 @@ export default function VideoPlayer({ videoId, title, nextVideoId }: VideoPlayer
if (data.event === 'onStateChange' && data.info === 0 && nextVideoId) {
router.push(`/watch?v=${nextVideoId}`);
}
} catch {}
} catch { }
};
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
@ -308,7 +318,7 @@ export default function VideoPlayer({ videoId, title, nextVideoId }: VideoPlayer
return (
<div style={containerStyle} onMouseEnter={() => setShowControls(true)} onMouseLeave={() => { setShowControls(false); setShowQualityMenu(false); }}>
{isLoading && <PlayerSkeleton />}
<video
ref={videoRef}
style={{ ...videoStyle, visibility: isLoading ? 'hidden' : 'visible' }}
@ -316,16 +326,16 @@ export default function VideoPlayer({ videoId, title, nextVideoId }: VideoPlayer
playsInline
poster={`https://i.ytimg.com/vi/${videoId}/maxresdefault.jpg`}
/>
{hasSeparateAudio && <audio ref={audioRef} style={{ display: 'none' }} />}
<audio ref={audioRef} style={{ display: 'none' }} />
{error && (
<div style={errorStyle}>
<span>{error}</span>
<button onClick={() => setUseFallback(true)} style={retryBtnStyle}>Try YouTube Player</button>
</div>
)}
{showControls && !error && !isLoading && (
<>
<a href={`https://www.youtube.com/watch?v=${videoId}`} target="_blank" rel="noopener noreferrer" style={openBtnStyle}>
@ -337,7 +347,7 @@ export default function VideoPlayer({ videoId, title, nextVideoId }: VideoPlayer
<button onClick={() => setShowQualityMenu(!showQualityMenu)} style={qualityBtnStyle}>
{qualities.find(q => q.height === currentQuality)?.label || 'Auto'}
</button>
{showQualityMenu && (
<div style={qualityMenuStyle}>
{qualities.map((q) => (

View file

@ -1,19 +1,11 @@
import { Suspense } from 'react';
import VideoPlayer from './VideoPlayer';
import Link from 'next/link';
import WatchActions from './WatchActions';
import SubscribeButton from '../components/SubscribeButton';
import RelatedVideos from './RelatedVideos';
import { API_BASE } from '../constants';
interface VideoData {
id: string;
title: string;
uploader: string;
channel_id?: string;
thumbnail: string;
view_count: number;
duration: string;
}
interface VideoInfo {
title: string;
description: string;
@ -42,24 +34,6 @@ async function getVideoInfo(id: string): Promise<VideoInfo | null> {
}
}
async function getRelatedVideos(videoId: string, title: string, uploader: string) {
try {
const params = new URLSearchParams({ v: videoId, title: title || '', uploader: uploader || '', limit: '15' });
const res = await fetch(`${API_BASE}/api/related?${params.toString()}`, { cache: 'no-store' });
if (!res.ok) return [];
return res.json() as Promise<VideoData[]>;
} catch (e) {
console.error(e);
return [];
}
}
function formatViews(views: number): string {
if (views >= 1000000) return (views / 1000000).toFixed(1) + 'M';
if (views >= 1000) return (views / 1000).toFixed(1) + 'K';
return views.toString();
}
function formatNumber(num: number): string {
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
if (num >= 1000) return (num / 1000).toFixed(0) + 'K';
@ -79,17 +53,14 @@ export default async function WatchPage({
}
const info = await getVideoInfo(v);
const relatedVideos = await getRelatedVideos(v, info?.title || '', info?.uploader || '');
const nextVideoId = relatedVideos.length > 0 ? relatedVideos[0].id : undefined;
return (
<div className="watch-container fade-in">
<div className="watch-primary">
<div className="watch-player-wrapper">
<VideoPlayer
videoId={v}
<VideoPlayer
videoId={v}
title={info?.title}
nextVideoId={nextVideoId}
/>
</div>
@ -127,30 +98,9 @@ export default async function WatchPage({
</div>
<div className="watch-secondary">
<div className="watch-related-list">
{relatedVideos.map((video, i) => {
const views = formatViews(video.view_count);
const staggerClass = `stagger-${Math.min(i + 1, 6)}`;
return (
<Link key={video.id} href={`/watch?v=${video.id}`} className={`related-video-item fade-in-up ${staggerClass}`} style={{ opacity: 0 }}>
<div className="related-thumb-container">
<img src={video.thumbnail} alt={video.title} className="related-thumb-img" />
{video.duration && (
<div className="duration-badge">
{video.duration}
</div>
)}
</div>
<div className="related-video-info">
<span className="related-video-title">{video.title}</span>
<span className="related-video-channel">{video.uploader}</span>
<span className="related-video-meta">{views} views</span>
</div>
</Link>
);
})}
</div>
<Suspense fallback={<div style={{ padding: '2rem', textAlign: 'center' }}><div className="skeleton" style={{ width: '100%', height: '100px', marginBottom: '1rem', borderRadius: '8px' }}></div><div className="skeleton" style={{ width: '100%', height: '100px', marginBottom: '1rem', borderRadius: '8px' }}></div><div className="skeleton" style={{ width: '100%', height: '100px', marginBottom: '1rem', borderRadius: '8px' }}></div></div>}>
<RelatedVideos videoId={v} title={info?.title || ''} uploader={info?.uploader || ''} />
</Suspense>
</div>
</div>
);

View file

@ -13,11 +13,11 @@ const nextConfig = {
return [
{
source: '/api/:path*',
destination: 'http://kv-tube-backend:8080/api/:path*',
destination: 'http://127.0.0.1:8080/api/:path*',
},
{
source: '/video_proxy',
destination: 'http://kv-tube-backend:8080/video_proxy',
destination: 'http://127.0.0.1:8080/video_proxy',
},
];
},

26
supervisord.conf Normal file
View file

@ -0,0 +1,26 @@
[supervisord]
nodaemon=true
logfile=/dev/null
logfile_maxbytes=0
[program:backend]
command=/app/kv-tube
directory=/app
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
environment=KVTUBE_DATA_DIR="/app/data",GIN_MODE="release",PORT="8080"
[program:frontend]
command=node server.js
directory=/app/frontend
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
environment=NODE_ENV="production",PORT="3000",HOSTNAME="0.0.0.0"