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)
FROM node:20-alpine AS frontend-builder
# Stage 1: Build Frontend
FROM --platform=linux/amd64 node:20-alpine AS frontend-builder
WORKDIR /app/frontend
COPY frontend-react/package*.json ./
RUN npm install
COPY frontend-react/ .
RUN npm run build
# Stage 2: Build Image (Backend)
FROM --platform=$BUILDPLATFORM golang:1.24-alpine AS backend-builder
# Stage 2: Build Backend for linux/amd64
FROM --platform=linux/amd64 golang:1.24-alpine AS backend-builder
WORKDIR /app/backend
ARG TARGETOS TARGETARCH
COPY backend/go.mod backend/go.sum ./
RUN go mod download
COPY backend/ .
# 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
FROM alpine:latest
# Stage 3: Final Image (linux/amd64 only for Synology NAS)
FROM --platform=linux/amd64 alpine:latest
WORKDIR /app
# Install runtime dependencies
RUN apk add --no-cache sqlite ca-certificates tzdata python3 py3-pip
RUN pip3 install --break-system-packages --ignore-installed yt-dlp || true
RUN apk add --no-cache sqlite ca-certificates tzdata
# Copy backend binary
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 --from=frontend-builder /app/frontend/dist ./dist
# Create data directory
RUN mkdir -p data
# Create data directory for SQLite database
RUN mkdir -p /app/data
# Environment variables
ENV PORT=8000
ENV DATABASE_URL=/app/data/streamflow.db
ENV TZ=Asia/Ho_Chi_Minh
# Expose port
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.
@ -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
- **Android TV** - Native TV app with D-pad controls and 10s skip
- **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)
## Tech Stack
@ -23,21 +24,45 @@ A high-performance video streaming web application with a pure Go backend and mo
## 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
# docker-compose.yml
version: '3.8'
services:
streamflow:
image: git.khoavo.myds.me/vndangkhoa/kv-netflix:v4
image: git.khoavo.myds.me/vndangkhoa/kv-netflix:v6
container_name: streamflow
platform: linux/amd64
ports:
- "3478:8000"
environment:
- DATABASE_URL=/app/data/streamflow.db
- PORT=8000
- TZ=Asia/Ho_Chi_Minh
volumes:
- ./data:/app/data
@ -51,7 +76,14 @@ services:
```
```bash
# Login to registry first
docker login git.khoavo.myds.me -u vndangkhoa -p Thieugia19
# Start container
docker-compose up -d
# Check logs
docker-compose logs -f
```
Access at: `http://YOUR_NAS_IP:3478`
@ -119,7 +151,17 @@ Streamflow/
## 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
- Refactored frontend and cleaned up repository

View file

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

View file

@ -32,7 +32,7 @@ export const Hero = ({ movies, variant = 'default' }: HeroProps) => {
};
// Helper to generate robust image URLs
const getImageUrl = (url: string | undefined, width: number, blur: number = 0) => {
const getImageUrl = (url: string | undefined) => {
if (!url) return '';
let cleanUrl = url;
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">
<img
key={movie.id}
src={getImageUrl(movie.backdrop || movie.thumbnail, 1600)}
src={getImageUrl(movie.backdrop || movie.thumbnail)}
alt={movie.title}
className="w-full h-full object-cover animate-fade-in"
onError={(e) => {
if (movie.thumbnail && e.currentTarget.src !== getImageUrl(movie.thumbnail, 1600)) {
e.currentTarget.src = getImageUrl(movie.thumbnail, 1600);
if (movie.thumbnail && e.currentTarget.src !== getImageUrl(movie.thumbnail)) {
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">
<img
key={movie.id}
src={getImageUrl(movie.backdrop || movie.thumbnail, 1600)}
src={getImageUrl(movie.backdrop || movie.thumbnail)}
alt={movie.title}
className="w-full h-full object-cover mask-image-gradient animate-fade-in"
onError={(e) => {
if (movie.thumbnail && e.currentTarget.src !== getImageUrl(movie.thumbnail, 1600)) {
e.currentTarget.src = getImageUrl(movie.thumbnail, 1600);
if (movie.thumbnail && e.currentTarget.src !== getImageUrl(movie.thumbnail)) {
e.currentTarget.src = getImageUrl(movie.thumbnail);
}
}}
/>
@ -188,12 +188,12 @@ export const Hero = ({ movies, variant = 'default' }: HeroProps) => {
<img
key={`bg-${movie.id}`}
// 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"
referrerPolicy="no-referrer"
onError={(e) => {
if (movie.thumbnail && e.currentTarget.src !== getImageUrl(movie.thumbnail, 1000)) {
e.currentTarget.src = getImageUrl(movie.thumbnail, 1000);
if (movie.thumbnail && e.currentTarget.src !== getImageUrl(movie.thumbnail)) {
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
@ -262,7 +262,7 @@ export const Hero = ({ movies, variant = 'default' }: HeroProps) => {
<img
key={`poster-${movie.id}`}
src={getImageUrl(movie.thumbnail || movie.backdrop, 600)}
src={getImageUrl(movie.thumbnail || movie.backdrop)}
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"
/>

View file

@ -17,7 +17,7 @@ export const MovieCard = ({ movie, className = '', isDragging = false }: MovieCa
? (movie.watchedTimestamp / movie.duration) * 100
: 0;
const getImageUrl = (url: string, width: number) => {
const getImageUrl = (url: string) => {
if (!url) return '';
let cleanUrl = url;
if (url.startsWith('//')) {
@ -39,7 +39,7 @@ export const MovieCard = ({ movie, className = '', isDragging = false }: MovieCa
>
{!imgError ? (
<img
src={getImageUrl(movie.thumbnail, 250)}
src={getImageUrl(movie.thumbnail)}
alt={movie.title}
onError={() => setImgError(true)}
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 type { MovieDetail, VideoSource } from '../types';
import { useWatchProgress } from './useWatchProgress';

View file

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

View file

@ -20,7 +20,7 @@ export const WatchPage = ({ slug, episode }: { slug: string, episode: string })
);
// Helper for URL safety (same as Hero)
const getImageUrl = (url: string | undefined, width: number) => {
const getImageUrl = (url: string | undefined) => {
if (!url) return '';
let cleanUrl = url;
if (url.startsWith('//')) {
@ -82,7 +82,7 @@ export const WatchPage = ({ slug, episode }: { slug: string, episode: string })
</div>
<div
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>
);
@ -94,7 +94,7 @@ export const WatchPage = ({ slug, episode }: { slug: string, episode: string })
ref={videoRef}
controls
className="w-full h-full max-h-screen object-contain"
poster={getImageUrl(movie.backdrop || movie.thumbnail, 1280)}
poster={getImageUrl(movie.backdrop || movie.thumbnail)}
/>
);
})()}