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
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:
parent
0819a1beca
commit
064377d7dd
8 changed files with 84 additions and 40 deletions
26
Dockerfile
26
Dockerfile
|
|
@ -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
|
||||||
|
|
|
||||||
52
README.md
52
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.
|
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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue