diff --git a/Dockerfile b/Dockerfile index 9f10120..dba10d4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index e308826..9637cf4 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 9bbfb65..29e583d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/frontend-react/src/components/Hero.tsx b/frontend-react/src/components/Hero.tsx index 766f4cb..25406b6 100644 --- a/frontend-react/src/components/Hero.tsx +++ b/frontend-react/src/components/Hero.tsx @@ -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) => {
{movie.title} { - 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) => {
{movie.title} { - 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) => { Background { - 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) => { {movie.title} diff --git a/frontend-react/src/components/MovieCard.tsx b/frontend-react/src/components/MovieCard.tsx index 0de6cd8..b2c9c46 100644 --- a/frontend-react/src/components/MovieCard.tsx +++ b/frontend-react/src/components/MovieCard.tsx @@ -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 ? ( {movie.title} setImgError(true)} className="w-full h-full object-cover transition-transform duration-700 group-hover/card:scale-110" diff --git a/frontend-react/src/hooks/useWatchMovie.ts b/frontend-react/src/hooks/useWatchMovie.ts index b50789b..f03bd48 100644 --- a/frontend-react/src/hooks/useWatchMovie.ts +++ b/frontend-react/src/hooks/useWatchMovie.ts @@ -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'; diff --git a/frontend-react/src/hooks/useWatchProgress.ts b/frontend-react/src/hooks/useWatchProgress.ts index f6837d9..2eb48c4 100644 --- a/frontend-react/src/hooks/useWatchProgress.ts +++ b/frontend-react/src/hooks/useWatchProgress.ts @@ -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, diff --git a/frontend-react/src/themes/default/WatchPage.tsx b/frontend-react/src/themes/default/WatchPage.tsx index 82a322d..ab74649 100644 --- a/frontend-react/src/themes/default/WatchPage.tsx +++ b/frontend-react/src/themes/default/WatchPage.tsx @@ -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 })
); @@ -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)} /> ); })()}