diff --git a/.env_example b/.env_example index 10474cf..fb6cb1b 100644 --- a/.env_example +++ b/.env_example @@ -14,6 +14,12 @@ ADMIN_PASSWORD=CHANGE_ME # Photo storage PHOTO_STORAGE_DIR=/opt/punimtag/data/uploads +# Pending viewer uploads (same value as viewer UPLOAD_DIR when using that feature). +# Web transcode cache defaults to a sibling folder: /web_videos next to +# .../pending-photos (override with WEB_VIDEO_CACHE_DIR if needed). +# UPLOAD_DIR=/mnt/db-server-uploads/pending-photos +# WEB_VIDEO_CACHE_DIR=/mnt/db-server-uploads/web_videos + # Redis (RQ jobs) REDIS_URL=redis://127.0.0.1:6379/0 diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 5767c8b..3c3d04b 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -735,7 +735,7 @@ jobs: npm run build continue-on-error: true env: - VITE_API_URL: http://localhost:8000 + VITE_API_URL: '' - name: Install viewer-frontend dependencies run: | diff --git a/.gitignore b/.gitignore index 66ca02d..2816edf 100644 --- a/.gitignore +++ b/.gitignore @@ -10,9 +10,10 @@ dist/ downloads/ eggs/ .eggs/ -# Python lib directories (but not viewer-frontend/lib/) +# Python lib directories (but not viewer-frontend/lib/ or admin-frontend TS lib/) lib/ !viewer-frontend/lib/ +!admin-frontend/src/lib/ lib64/ parts/ sdist/ @@ -83,3 +84,4 @@ data/thumbnails/ # PM2 ecosystem config (server-specific paths) ecosystem.config.js +data/web_videos/ diff --git a/admin-frontend/.env_example b/admin-frontend/.env_example index e9e5bd3..790cbb9 100644 --- a/admin-frontend/.env_example +++ b/admin-frontend/.env_example @@ -1,8 +1,14 @@ # Admin frontend env (copy to ".env" ) -# Backend API base URL (must be reachable from the browser) +# Backend API origin as seen by the browser. Leave empty for local dev: Vite proxies +# /api to http://127.0.0.1:8000 (see vite.config.ts). +# Production (same host as admin, proxy at /punim-api/): VITE_API_URL=/punim-api VITE_API_URL= +# Production subpath for static assets (Vite base + React Router basename). +# Local dev: leave unset (served at http://localhost:3000/). +# VITE_BASE_PATH=/punim-admin + # Enable developer mode (shows additional debug info and options) # Set to "true" to enable, leave empty or unset to disable VITE_DEVELOPER_MODE= diff --git a/admin-frontend/public/logo.svg b/admin-frontend/public/logo.svg new file mode 100644 index 0000000..28090da Binary files /dev/null and b/admin-frontend/public/logo.svg differ diff --git a/admin-frontend/src/App.tsx b/admin-frontend/src/App.tsx index 04f7c28..b1d4094 100644 --- a/admin-frontend/src/App.tsx +++ b/admin-frontend/src/App.tsx @@ -171,7 +171,11 @@ function App() { return ( - + diff --git a/admin-frontend/src/api/client.ts b/admin-frontend/src/api/client.ts index fb6729d..2553715 100644 --- a/admin-frontend/src/api/client.ts +++ b/admin-frontend/src/api/client.ts @@ -1,9 +1,7 @@ import axios from 'axios' -// Get API base URL from environment variable or use default -// The .env file should contain: VITE_API_URL=http://127.0.0.1:8000 -// Alternatively, Vite proxy can be used (configured in vite.config.ts) by setting VITE_API_URL to empty string -// When VITE_API_URL is empty/undefined, use relative path to work with HTTPS proxy +// API origin as seen by the browser. For local dev, leave VITE_API_URL unset/empty so +// requests use relative /api/v1/... and Vite proxies /api → http://127.0.0.1:8000. const envApiUrl = import.meta.env.VITE_API_URL const API_BASE_URL = envApiUrl && envApiUrl.trim() !== '' ? envApiUrl diff --git a/admin-frontend/src/api/videos.ts b/admin-frontend/src/api/videos.ts index 45fdc73..3ad7bdc 100644 --- a/admin-frontend/src/api/videos.ts +++ b/admin-frontend/src/api/videos.ts @@ -1,4 +1,5 @@ import apiClient from './client' +import { fastApiV1Path } from '../lib/fastapi-path' export interface PersonInfo { id: number @@ -65,6 +66,21 @@ export interface RemoveVideoPersonResponse { message: string } +export interface WebPlaybackPrepareResponse { + status: string + message: string +} + +export interface WebPlaybackStatusResponse { + status: string + error?: string | null +} + +function apiBasePath(): string { + const envApiUrl = import.meta.env.VITE_API_URL + return envApiUrl && envApiUrl.trim() !== '' ? envApiUrl : '' +} + export const videosApi = { listVideos: async (params: { page?: number @@ -77,12 +93,14 @@ export const videosApi = { sort_by?: string sort_dir?: string }): Promise => { - const res = await apiClient.get('/api/v1/videos', { params }) + const res = await apiClient.get(fastApiV1Path('/videos'), { params }) return res.data }, getVideoPeople: async (videoId: number): Promise => { - const res = await apiClient.get(`/api/v1/videos/${videoId}/people`) + const res = await apiClient.get( + fastApiV1Path(`/videos/${videoId}/people`) + ) return res.data }, @@ -91,7 +109,7 @@ export const videosApi = { request: IdentifyVideoRequest ): Promise => { const res = await apiClient.post( - `/api/v1/videos/${videoId}/identify`, + fastApiV1Path(`/videos/${videoId}/identify`), request ) return res.data @@ -102,25 +120,42 @@ export const videosApi = { personId: number ): Promise => { const res = await apiClient.delete( - `/api/v1/videos/${videoId}/people/${personId}` + fastApiV1Path(`/videos/${videoId}/people/${personId}`) ) return res.data }, getThumbnailUrl: (videoId: number): string => { - const envApiUrl = import.meta.env.VITE_API_URL - const baseURL = envApiUrl && envApiUrl.trim() !== '' - ? envApiUrl - : '' // Use relative path when empty - works with proxy and HTTPS - return `${baseURL}/api/v1/videos/${videoId}/thumbnail` + const baseURL = apiBasePath() + return `${baseURL}${fastApiV1Path(`/videos/${videoId}/thumbnail`)}` }, getVideoUrl: (videoId: number): string => { - const envApiUrl = import.meta.env.VITE_API_URL - const baseURL = envApiUrl && envApiUrl.trim() !== '' - ? envApiUrl - : '' // Use relative path when empty - works with proxy and HTTPS - return `${baseURL}/api/v1/videos/${videoId}/video` + const baseURL = apiBasePath() + return `${baseURL}${fastApiV1Path(`/videos/${videoId}/video`)}` + }, + + getWebPlaybackStreamUrl: (videoId: number): string => { + const baseURL = apiBasePath() + return `${baseURL}${fastApiV1Path(`/videos/${videoId}/web-playback/stream`)}` + }, + + prepareWebPlayback: async ( + videoId: number + ): Promise => { + const res = await apiClient.post( + fastApiV1Path(`/videos/${videoId}/web-playback/prepare`) + ) + return res.data + }, + + getWebPlaybackStatus: async ( + videoId: number + ): Promise => { + const res = await apiClient.get( + fastApiV1Path(`/videos/${videoId}/web-playback/status`) + ) + return res.data }, } diff --git a/admin-frontend/src/components/PhotoViewer.tsx b/admin-frontend/src/components/PhotoViewer.tsx index 2b0df99..97d571b 100644 --- a/admin-frontend/src/components/PhotoViewer.tsx +++ b/admin-frontend/src/components/PhotoViewer.tsx @@ -2,6 +2,8 @@ import { useEffect, useState, useRef } from 'react' import { PhotoSearchResult, photosApi } from '../api/photos' import { apiClient } from '../api/client' import videosApi from '../api/videos' +import { useWebPlaybackVideo } from '../hooks/useWebPlaybackVideo' +import { isRemoteMediaPath } from '../lib/media-path' interface PhotoViewerProps { photos: PhotoSearchResult[] @@ -52,13 +54,14 @@ export default function PhotoViewer({ photos, initialIndex, onClose }: PhotoView return photo.media_type === 'video' } - // Get photo/video URL - const getPhotoUrl = (photoId: number, mediaType?: string) => { - if (mediaType === 'video') { - return videosApi.getVideoUrl(photoId) - } - return `${apiClient.defaults.baseURL}/api/v1/photos/${photoId}/image` - } + const currentIsVideo = currentPhoto ? isVideo(currentPhoto) : false + const webPlayback = useWebPlaybackVideo( + currentPhoto && currentIsVideo ? currentPhoto.id : null, + currentPhoto && currentIsVideo ? currentPhoto.path : '' + ) + + const getImageUrl = (photoId: number) => + `${apiClient.defaults.baseURL}/api/v1/photos/${photoId}/image` // Preload adjacent images (skip videos) const preloadAdjacent = (index: number) => { @@ -69,7 +72,7 @@ export default function PhotoViewer({ photos, initialIndex, onClose }: PhotoView const nextPhotoId = nextPhoto.id if (!preloadedImages.current.has(nextPhotoId)) { const img = new Image() - img.src = getPhotoUrl(nextPhotoId, nextPhoto.media_type) + img.src = getImageUrl(nextPhotoId) preloadedImages.current.add(nextPhotoId) } } @@ -81,7 +84,7 @@ export default function PhotoViewer({ photos, initialIndex, onClose }: PhotoView const prevPhotoId = prevPhoto.id if (!preloadedImages.current.has(prevPhotoId)) { const img = new Image() - img.src = getPhotoUrl(prevPhotoId, prevPhoto.media_type) + img.src = getImageUrl(prevPhotoId) preloadedImages.current.add(prevPhotoId) } } @@ -192,7 +195,9 @@ export default function PhotoViewer({ photos, initialIndex, onClose }: PhotoView useEffect(() => { if (!currentPhoto) return - setImageLoading(true) + if (currentPhoto.media_type !== 'video') { + setImageLoading(true) + } setImageError(false) // Reset zoom when photo changes setZoom(1) @@ -211,6 +216,52 @@ export default function PhotoViewer({ photos, initialIndex, onClose }: PhotoView preloadAdjacent(currentIndex) }, [currentIndex, currentPhoto, photos.length]) + // Sync loading / error for local web playback (videos) + useEffect(() => { + if (!currentPhoto || currentPhoto.media_type !== 'video') { + return + } + if (webPlayback.error) { + setImageError(true) + setImageLoading(false) + return + } + if (!webPlayback.videoSrc || webPlayback.preparing) { + setImageLoading(true) + setImageError(false) + return + } + setImageError(false) + setImageLoading(true) + }, [ + currentPhoto?.id, + currentPhoto?.media_type, + webPlayback.error, + webPlayback.preparing, + webPlayback.videoSrc, + ]) + + // Prefetch browser-safe transcode for nearby local videos (viewer parity) + useEffect(() => { + if (photos.length === 0) { + return + } + const idxs = [ + currentIndex - 2, + currentIndex - 1, + currentIndex + 1, + currentIndex + 2, + ].filter((i) => i >= 0 && i < photos.length) + + for (const i of idxs) { + const p = photos[i] + if (!isVideo(p) || isRemoteMediaPath(p.path)) { + continue + } + videosApi.prepareWebPlayback(p.id).catch(() => {}) + } + }, [currentIndex, photos]) + // Toggle favorite const toggleFavorite = async () => { if (loadingFavorite || !currentPhoto) return @@ -273,8 +324,7 @@ export default function PhotoViewer({ photos, initialIndex, onClose }: PhotoView return null } - const photoUrl = getPhotoUrl(currentPhoto.id, currentPhoto.media_type) - const currentIsVideo = isVideo(currentPhoto) + const imagePhotoUrl = getImageUrl(currentPhoto.id) return (
@@ -365,19 +415,22 @@ export default function PhotoViewer({ photos, initialIndex, onClose }: PhotoView {imageError ? (
Failed to load {currentIsVideo ? 'video' : 'image'}
+ {webPlayback.error && ( +
{webPlayback.error}
+ )}
{currentPhoto.path}
) : currentIsVideo ? (
- + /> {selectedVideoToPlay.date_taken && (
Date taken: {new Date(selectedVideoToPlay.date_taken).toLocaleDateString()} diff --git a/admin-frontend/src/pages/ReportedPhotos.tsx b/admin-frontend/src/pages/ReportedPhotos.tsx index 49910ee..0f29d61 100644 --- a/admin-frontend/src/pages/ReportedPhotos.tsx +++ b/admin-frontend/src/pages/ReportedPhotos.tsx @@ -363,7 +363,7 @@ export default function ReportedPhotos() { onClick={() => { const isVideo = reported.photo_media_type === 'video' const url = isVideo - ? videosApi.getVideoUrl(reported.photo_id) + ? `/video/${reported.photo_id}` : `${apiClient.defaults.baseURL}/api/v1/photos/${reported.photo_id}/image` window.open(url, '_blank') }} diff --git a/admin-frontend/src/pages/Search.tsx b/admin-frontend/src/pages/Search.tsx index e2acd17..d48566d 100644 --- a/admin-frontend/src/pages/Search.tsx +++ b/admin-frontend/src/pages/Search.tsx @@ -309,7 +309,9 @@ export default function Search() { } catch (error: any) { console.error('Error searching photos:', error) const errorMessage = error.response?.data?.detail || error.message || 'Unknown error' - alert(`Error searching photos: ${errorMessage}\n\nPlease check:\n1. Backend API is running (http://127.0.0.1:8000)\n2. You are logged in\n3. Database connection is working`) + alert( + `Error searching photos: ${errorMessage}\n\nPlease check:\n1. Backend API is running\n2. You are logged in\n3. Database connection is working` + ) } finally { setLoading(false) } diff --git a/admin-frontend/src/pages/Tags.tsx b/admin-frontend/src/pages/Tags.tsx index 2327155..98859a7 100644 --- a/admin-frontend/src/pages/Tags.tsx +++ b/admin-frontend/src/pages/Tags.tsx @@ -2,6 +2,7 @@ import React, { useState, useEffect, useMemo, useRef } from 'react' import tagsApi, { PhotoWithTagsItem, TagResponse } from '../api/tags' import { useDeveloperMode } from '../context/DeveloperModeContext' import { apiClient } from '../api/client' +import videosApi from '../api/videos' type ViewMode = 'list' | 'icons' | 'compact' @@ -754,10 +755,19 @@ export default function Tags() { {photo.id} {photo.filename} @@ -872,7 +882,12 @@ export default function Tags() { {folderStates[folder.folderPath] === true && (
{folder.photos.map(photo => { - const photoUrl = `${apiClient.defaults.baseURL}/api/v1/photos/${photo.id}/image` + const isVideo = photo.media_type === 'video' + const imageUrl = `${apiClient.defaults.baseURL}/api/v1/photos/${photo.id}/image` + const thumbUrl = isVideo + ? videosApi.getThumbnailUrl(photo.id) + : imageUrl + const openUrl = isVideo ? `/video/${photo.id}` : imageUrl const isSelected = selectedPhotoIds.has(photo.id) return ( @@ -928,10 +943,13 @@ export default function Tags() { {/* Thumbnail */}
{photo.filename} window.open(photoUrl, '_blank')} + onClick={() => window.open(openUrl, '_blank')} + title={ + isVideo ? 'Open video' : 'Open full photo' + } onError={(e) => { const target = e.target as HTMLImageElement target.src = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="150" height="150"%3E%3Crect fill="%23ddd" width="150" height="150"/%3E%3Ctext x="50%25" y="50%25" text-anchor="middle" dy=".3em" fill="%23999"%3ENo Image%3C/text%3E%3C/svg%3E' diff --git a/admin-frontend/src/pages/UserTaggedPhotos.tsx b/admin-frontend/src/pages/UserTaggedPhotos.tsx index 83fd100..ae47866 100644 --- a/admin-frontend/src/pages/UserTaggedPhotos.tsx +++ b/admin-frontend/src/pages/UserTaggedPhotos.tsx @@ -443,7 +443,7 @@ export default function UserTaggedPhotos() { onClick={() => { const isVideo = linkage.photo_media_type === 'video' const url = isVideo - ? videosApi.getVideoUrl(linkage.photo_id) + ? `/video/${linkage.photo_id}` : `${apiClient.defaults.baseURL}/api/v1/photos/${linkage.photo_id}/image` window.open(url, '_blank') }} diff --git a/admin-frontend/src/pages/VideoPlayer.tsx b/admin-frontend/src/pages/VideoPlayer.tsx index 18d7249..4072e54 100644 --- a/admin-frontend/src/pages/VideoPlayer.tsx +++ b/admin-frontend/src/pages/VideoPlayer.tsx @@ -1,14 +1,45 @@ import { useState, useRef, useEffect } from 'react' import { useParams } from 'react-router-dom' -import videosApi from '../api/videos' +import { photosApi } from '../api/photos' +import { WebPlaybackVideo } from '../components/WebPlaybackVideo' export default function VideoPlayer() { const { id } = useParams<{ id: string }>() - const videoId = id ? parseInt(id, 10) : null + const parsed = id ? parseInt(id, 10) : NaN + const videoId = Number.isFinite(parsed) ? parsed : null const videoRef = useRef(null) const [showPlayButton, setShowPlayButton] = useState(true) + const [mediaPath, setMediaPath] = useState(null) + const [pathError, setPathError] = useState(null) - const videoUrl = videoId ? videosApi.getVideoUrl(videoId) : '' + useEffect(() => { + if (videoId === null || Number.isNaN(videoId)) { + setMediaPath(null) + setPathError(null) + return + } + let cancelled = false + setMediaPath(null) + setPathError(null) + photosApi + .getPhoto(videoId) + .then((p) => { + if (!cancelled) { + setMediaPath(p.path) + } + }) + .catch((e) => { + if (!cancelled) { + setPathError( + e?.response?.data?.detail || e?.message || 'Failed to load video' + ) + setMediaPath('') + } + }) + return () => { + cancelled = true + } + }, [videoId]) const handlePlay = () => { if (videoRef.current) { @@ -53,7 +84,7 @@ export default function VideoPlayer() { } }, []) - if (!videoId || !videoUrl) { + if (videoId === null || Number.isNaN(videoId)) { return (
Video not found
@@ -61,12 +92,29 @@ export default function VideoPlayer() { ) } + if (pathError && mediaPath === '') { + return ( +
+
{pathError}
+
+ ) + } + + if (mediaPath === null) { + return ( +
+
Loading…
+
+ ) + } + return (
-
) : (
/dev/null; then + for dep in "${MISSING_DEPS[@]}"; do + if [ "$dep" = "ffmpeg" ]; then + echo "Installing ffmpeg with dnf..." + echo "Note: On EL8 (Alma/Rocky/CentOS), ffmpeg may require RPM Fusion repos." + echo "" + if sudo dnf -y install ffmpeg; then + echo "✅ ffmpeg installed" + else + echo "" + echo "⚠️ dnf couldn't find ffmpeg." + echo " If this is EL8, run the helper installer from the repo root:" + echo " sudo bash ../../scripts/install_ffmpeg_el8_rpmfusion.sh" + echo "" + fi + fi + done elif command -v brew &> /dev/null; then for dep in "${MISSING_DEPS[@]}"; do if [ "$dep" = "libvips-dev" ]; then