build: Update Dockerfile for v6 and fix TypeScript errors
Some checks failed
StreamFlow CI/CD / Backend Tests (push) Failing after 3s
StreamFlow CI/CD / Backend Lint (push) Failing after 2s
StreamFlow CI/CD / Frontend Tests (push) Failing after 2s
StreamFlow CI/CD / Docker Build (push) Has been skipped
StreamFlow CI/CD / Android TV Build (push) Failing after 1s
StreamFlow CI/CD / Docker Publish (push) Failing after 5s

- Update Dockerfile to use linux/amd64 platform consistently
- Fix unused parameter warnings in Hero.tsx, MovieCard.tsx
- Fix useCallback import issues in useWatchProgress.ts
- Update docker-compose.yml to use v6 tag
- Update README.md with Synology NAS deployment instructions
- Add episode progress tracking documentation
This commit is contained in:
vndangkhoa 2026-05-07 07:38:39 +07:00
parent 0819a1beca
commit 064377d7dd
8 changed files with 84 additions and 40 deletions

View file

@ -1,31 +1,28 @@
# Stage 1: Build Image (Frontend) # Stage 1: Build Frontend
FROM node:20-alpine AS frontend-builder FROM --platform=linux/amd64 node:20-alpine AS frontend-builder
WORKDIR /app/frontend WORKDIR /app/frontend
COPY frontend-react/package*.json ./ COPY frontend-react/package*.json ./
RUN npm install RUN npm install
COPY frontend-react/ . COPY frontend-react/ .
RUN npm run build RUN npm run build
# Stage 2: Build Image (Backend) # Stage 2: Build Backend for linux/amd64
FROM --platform=$BUILDPLATFORM golang:1.24-alpine AS backend-builder FROM --platform=linux/amd64 golang:1.24-alpine AS backend-builder
WORKDIR /app/backend WORKDIR /app/backend
ARG TARGETOS TARGETARCH
COPY backend/go.mod backend/go.sum ./ COPY backend/go.mod backend/go.sum ./
RUN go mod download RUN go mod download
COPY backend/ . COPY backend/ .
# Build static binary for Linux amd64 # Build static binary for Linux amd64
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags="-w -s" -o server cmd/server/main.go RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-w -s" -o server cmd/server/main.go
# Stage 3: Final Image # Stage 3: Final Image (linux/amd64 only for Synology NAS)
FROM alpine:latest FROM --platform=linux/amd64 alpine:latest
WORKDIR /app WORKDIR /app
# Install runtime dependencies # Install runtime dependencies
RUN apk add --no-cache sqlite ca-certificates tzdata python3 py3-pip RUN apk add --no-cache sqlite ca-certificates tzdata
RUN pip3 install --break-system-packages --ignore-installed yt-dlp || true
# Copy backend binary # Copy backend binary
COPY --from=backend-builder /app/backend/server . COPY --from=backend-builder /app/backend/server .
@ -33,14 +30,13 @@ COPY --from=backend-builder /app/backend/server .
# Copy frontend build to the expected static directory # Copy frontend build to the expected static directory
COPY --from=frontend-builder /app/frontend/dist ./dist COPY --from=frontend-builder /app/frontend/dist ./dist
# Create data directory for SQLite database
RUN mkdir -p /app/data
# Create data directory
RUN mkdir -p data
# Environment variables # Environment variables
ENV PORT=8000 ENV PORT=8000
ENV DATABASE_URL=/app/data/streamflow.db ENV DATABASE_URL=/app/data/streamflow.db
ENV TZ=Asia/Ho_Chi_Minh
# Expose port # Expose port
EXPOSE 8000 EXPOSE 8000

View file

@ -1,4 +1,4 @@
# kv-netflix V4 # kv-netflix V6
A high-performance video streaming web application with a pure Go backend and modern React + Tailwind frontend. A high-performance video streaming web application with a pure Go backend and modern React + Tailwind frontend.
@ -10,6 +10,7 @@ A high-performance video streaming web application with a pure Go backend and mo
- **HLS Streaming** - Native HLS playback with proxy support - **HLS Streaming** - Native HLS playback with proxy support
- **Android TV** - Native TV app with D-pad controls and 10s skip - **Android TV** - Native TV app with D-pad controls and 10s skip
- **PWA Support** - Install as a progressive web app - **PWA Support** - Install as a progressive web app
- **Episode Progress Tracking** - Auto-save progress, continue watching with seek
- **Docker Ready** - Multi-stage build for Synology NAS (linux/amd64) - **Docker Ready** - Multi-stage build for Synology NAS (linux/amd64)
## Tech Stack ## Tech Stack
@ -23,21 +24,45 @@ A high-performance video streaming web application with a pure Go backend and mo
## Quick Start ## Quick Start
### Docker (Recommended) ### Docker (Recommended for Synology NAS)
**Prerequisites:**
- Synology NAS with Container Manager (Docker) installed
- SSH access enabled (optional, for CLI) or use Container Manager GUI
**Option 1: Container Manager GUI (Recommended for Synology)**
1. Open **Container Manager** on your Synology NAS
2. Go to **Registry** tab and add your Forgejo registry:
- Registry URL: `git.khoavo.myds.me`
- Username: `vndangkhoa`
- Password: `Thieugia19`
3. Search for `vndangkhoa/kv-netflix` and download `v6` tag
4. Create a new container:
- **Image**: `git.khoavo.myds.me/vndangkhoa/kv-netflix:v6`
- **Container name**: `streamflow`
- **Network**: Bridge mode, map port `3478` (local) → `8000` (container)
- **Environment**: Add `TZ=Asia/Ho_Chi_Minh`
- **Volume**: Create folder `docker/streamflow/data` on NAS, map to `/app/data`
- **Restart policy**: `Unless stopped`
5. Start the container
**Option 2: Docker Compose (SSH/CLI)**
Create `docker-compose.yml` on your NAS:
```yaml ```yaml
# docker-compose.yml
version: '3.8' version: '3.8'
services: services:
streamflow: streamflow:
image: git.khoavo.myds.me/vndangkhoa/kv-netflix:v4 image: git.khoavo.myds.me/vndangkhoa/kv-netflix:v6
container_name: streamflow container_name: streamflow
platform: linux/amd64 platform: linux/amd64
ports: ports:
- "3478:8000" - "3478:8000"
environment: environment:
- DATABASE_URL=/app/data/streamflow.db - DATABASE_URL=/app/data/streamflow.db
- PORT=8000
- TZ=Asia/Ho_Chi_Minh - TZ=Asia/Ho_Chi_Minh
volumes: volumes:
- ./data:/app/data - ./data:/app/data
@ -51,7 +76,14 @@ services:
``` ```
```bash ```bash
# Login to registry first
docker login git.khoavo.myds.me -u vndangkhoa -p Thieugia19
# Start container
docker-compose up -d docker-compose up -d
# Check logs
docker-compose logs -f
``` ```
Access at: `http://YOUR_NAS_IP:3478` Access at: `http://YOUR_NAS_IP:3478`
@ -119,7 +151,17 @@ Streamflow/
## Changelog ## Changelog
### v4 (Current) ### v6 (Current)
- Episode progress tracking with auto-save (every 5s + on pause)
- Continue Watching section with progress bars
- Seek to saved position minus 20 seconds on return
- Fixed ophim image URLs (migrated to img.ophim.live)
- Removed broken wsrv.nl proxy dependency
- Episode badge and progress bar in MovieCard
- Pushed to Forgejo: `git.khoavo.myds.me/vndangkhoa/kv-netflix:v6`
- Docker multi-stage build optimized for Synology NAS (linux/amd64)
### v4
- Deployed v4 to Forgejo and Docker Registry - Deployed v4 to Forgejo and Docker Registry
- Refactored frontend and cleaned up repository - Refactored frontend and cleaned up repository

View file

@ -2,7 +2,7 @@ version: '3.8'
services: services:
streamflow: streamflow:
image: git.khoavo.myds.me/vndangkhoa/kv-netflix:v4 image: git.khoavo.myds.me/vndangkhoa/kv-netflix:v6
container_name: streamflow container_name: streamflow
platform: linux/amd64 platform: linux/amd64
ports: ports:
@ -12,10 +12,11 @@ services:
- PORT=8000 - PORT=8000
- TZ=Asia/Ho_Chi_Minh - TZ=Asia/Ho_Chi_Minh
volumes: volumes:
# Synology: Use relative path for data persistence
- ./data:/app/data - ./data:/app/data
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
test: [ "CMD", "wget", "-q", "--spider", "http://localhost:8000/api/health" ] test: ["CMD", "wget", "-q", "--spider", "http://localhost:8000/api/health"]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3

View file

@ -32,7 +32,7 @@ export const Hero = ({ movies, variant = 'default' }: HeroProps) => {
}; };
// Helper to generate robust image URLs // Helper to generate robust image URLs
const getImageUrl = (url: string | undefined, width: number, blur: number = 0) => { const getImageUrl = (url: string | undefined) => {
if (!url) return ''; if (!url) return '';
let cleanUrl = url; let cleanUrl = url;
if (url.startsWith('//')) { if (url.startsWith('//')) {
@ -52,12 +52,12 @@ export const Hero = ({ movies, variant = 'default' }: HeroProps) => {
<div className="absolute inset-0 scale-105 transition-transform duration-[10000ms] ease-linear"> <div className="absolute inset-0 scale-105 transition-transform duration-[10000ms] ease-linear">
<img <img
key={movie.id} key={movie.id}
src={getImageUrl(movie.backdrop || movie.thumbnail, 1600)} src={getImageUrl(movie.backdrop || movie.thumbnail)}
alt={movie.title} alt={movie.title}
className="w-full h-full object-cover animate-fade-in" className="w-full h-full object-cover animate-fade-in"
onError={(e) => { onError={(e) => {
if (movie.thumbnail && e.currentTarget.src !== getImageUrl(movie.thumbnail, 1600)) { if (movie.thumbnail && e.currentTarget.src !== getImageUrl(movie.thumbnail)) {
e.currentTarget.src = getImageUrl(movie.thumbnail, 1600); e.currentTarget.src = getImageUrl(movie.thumbnail);
} }
}} }}
/> />
@ -119,12 +119,12 @@ export const Hero = ({ movies, variant = 'default' }: HeroProps) => {
<div className="absolute inset-0 transition-opacity duration-1000 ease-in-out"> <div className="absolute inset-0 transition-opacity duration-1000 ease-in-out">
<img <img
key={movie.id} key={movie.id}
src={getImageUrl(movie.backdrop || movie.thumbnail, 1600)} src={getImageUrl(movie.backdrop || movie.thumbnail)}
alt={movie.title} alt={movie.title}
className="w-full h-full object-cover mask-image-gradient animate-fade-in" className="w-full h-full object-cover mask-image-gradient animate-fade-in"
onError={(e) => { onError={(e) => {
if (movie.thumbnail && e.currentTarget.src !== getImageUrl(movie.thumbnail, 1600)) { if (movie.thumbnail && e.currentTarget.src !== getImageUrl(movie.thumbnail)) {
e.currentTarget.src = getImageUrl(movie.thumbnail, 1600); e.currentTarget.src = getImageUrl(movie.thumbnail);
} }
}} }}
/> />
@ -188,12 +188,12 @@ export const Hero = ({ movies, variant = 'default' }: HeroProps) => {
<img <img
key={`bg-${movie.id}`} key={`bg-${movie.id}`}
// Use thumbnail as safe default since we blur it anyway // Use thumbnail as safe default since we blur it anyway
src={getImageUrl(movie.thumbnail || movie.backdrop, 1000)} src={getImageUrl(movie.thumbnail || movie.backdrop)}
alt="Background" alt="Background"
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
onError={(e) => { onError={(e) => {
if (movie.thumbnail && e.currentTarget.src !== getImageUrl(movie.thumbnail, 1000)) { if (movie.thumbnail && e.currentTarget.src !== getImageUrl(movie.thumbnail)) {
e.currentTarget.src = getImageUrl(movie.thumbnail, 1000); e.currentTarget.src = getImageUrl(movie.thumbnail);
} }
}} }}
className="w-full h-full object-cover opacity-50 scale-110 blur-xl" // CSS Blur instead of API blur className="w-full h-full object-cover opacity-50 scale-110 blur-xl" // CSS Blur instead of API blur
@ -262,7 +262,7 @@ export const Hero = ({ movies, variant = 'default' }: HeroProps) => {
<img <img
key={`poster-${movie.id}`} key={`poster-${movie.id}`}
src={getImageUrl(movie.thumbnail || movie.backdrop, 600)} src={getImageUrl(movie.thumbnail || movie.backdrop)}
alt={movie.title} alt={movie.title}
className="relative w-[280px] lg:w-[350px] aspect-[2/3] object-cover rounded-xl shadow-2xl shadow-black/50 ring-1 ring-white/10 transform transition-all duration-500 group-hover/poster:scale-[1.02] group-hover/poster:-rotate-1" className="relative w-[280px] lg:w-[350px] aspect-[2/3] object-cover rounded-xl shadow-2xl shadow-black/50 ring-1 ring-white/10 transform transition-all duration-500 group-hover/poster:scale-[1.02] group-hover/poster:-rotate-1"
/> />

View file

@ -17,7 +17,7 @@ export const MovieCard = ({ movie, className = '', isDragging = false }: MovieCa
? (movie.watchedTimestamp / movie.duration) * 100 ? (movie.watchedTimestamp / movie.duration) * 100
: 0; : 0;
const getImageUrl = (url: string, width: number) => { const getImageUrl = (url: string) => {
if (!url) return ''; if (!url) return '';
let cleanUrl = url; let cleanUrl = url;
if (url.startsWith('//')) { if (url.startsWith('//')) {
@ -39,7 +39,7 @@ export const MovieCard = ({ movie, className = '', isDragging = false }: MovieCa
> >
{!imgError ? ( {!imgError ? (
<img <img
src={getImageUrl(movie.thumbnail, 250)} src={getImageUrl(movie.thumbnail)}
alt={movie.title} alt={movie.title}
onError={() => setImgError(true)} onError={() => setImgError(true)}
className="w-full h-full object-cover transition-transform duration-700 group-hover/card:scale-110" className="w-full h-full object-cover transition-transform duration-700 group-hover/card:scale-110"

View file

@ -1,4 +1,4 @@
import { useState, useEffect, useRef, useCallback } from 'react'; import { useState, useEffect, useRef } from 'react';
import Hls from 'hls.js'; import Hls from 'hls.js';
import type { MovieDetail, VideoSource } from '../types'; import type { MovieDetail, VideoSource } from '../types';
import { useWatchProgress } from './useWatchProgress'; import { useWatchProgress } from './useWatchProgress';

View file

@ -8,6 +8,11 @@ interface ProgressData {
timestamp: number; timestamp: number;
duration: number; duration: number;
updatedAt: string; updatedAt: string;
movieTitle?: string;
movieThumbnail?: string;
movieBackdrop?: string;
movieYear?: number;
movieCategory?: string;
} }
interface StoredProgress { interface StoredProgress {
@ -77,7 +82,7 @@ export const useWatchProgress = () => {
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
}, [progressMap]); }, [progressMap]);
const getContinueWatchingMovies = useCallback((): Movie[] => { const getContinueWatchingMovies = useCallback(() => {
return Object.entries(progressMap) return Object.entries(progressMap)
.map(([slug, data]) => ({ .map(([slug, data]) => ({
id: slug, id: slug,

View file

@ -20,7 +20,7 @@ export const WatchPage = ({ slug, episode }: { slug: string, episode: string })
); );
// Helper for URL safety (same as Hero) // Helper for URL safety (same as Hero)
const getImageUrl = (url: string | undefined, width: number) => { const getImageUrl = (url: string | undefined) => {
if (!url) return ''; if (!url) return '';
let cleanUrl = url; let cleanUrl = url;
if (url.startsWith('//')) { if (url.startsWith('//')) {
@ -82,7 +82,7 @@ export const WatchPage = ({ slug, episode }: { slug: string, episode: string })
</div> </div>
<div <div
className="absolute inset-0 -z-10 opacity-30 bg-cover bg-center blur-2xl grayscale" className="absolute inset-0 -z-10 opacity-30 bg-cover bg-center blur-2xl grayscale"
style={{ backgroundImage: `url(${getImageUrl(movie.backdrop || movie.thumbnail, 400)})` }} style={{ backgroundImage: `url(${getImageUrl(movie.backdrop || movie.thumbnail)})` }}
/> />
</div> </div>
); );
@ -94,7 +94,7 @@ export const WatchPage = ({ slug, episode }: { slug: string, episode: string })
ref={videoRef} ref={videoRef}
controls controls
className="w-full h-full max-h-screen object-contain" className="w-full h-full max-h-screen object-contain"
poster={getImageUrl(movie.backdrop || movie.thumbnail, 1280)} poster={getImageUrl(movie.backdrop || movie.thumbnail)}
/> />
); );
})()} })()}