diff --git a/.replit b/.replit index c25f461..adcfadc 100644 --- a/.replit +++ b/.replit @@ -4,7 +4,6 @@ hidden = [".config", ".git", "generated-icon.png", "node_modules", "dist"] [nix] channel = "stable-24_05" -packages = ["ffmpeg", "imagemagick"] [deployment] deploymentTarget = "autoscale" @@ -38,6 +37,3 @@ author = "agent" task = "shell.exec" args = "npm run dev" waitForPort = 5000 - -[agent] -integrations = ["javascript_database==1.0.0", "javascript_log_in_with_replit==1.0.0"] diff --git a/attached_assets/IMG_0270_1754336005971.png b/attached_assets/IMG_0270_1754336005971.png deleted file mode 100644 index c43db88..0000000 Binary files a/attached_assets/IMG_0270_1754336005971.png and /dev/null differ diff --git a/attached_assets/IMG_0271_1754336220862.png b/attached_assets/IMG_0271_1754336220862.png deleted file mode 100644 index 2f78289..0000000 Binary files a/attached_assets/IMG_0271_1754336220862.png and /dev/null differ diff --git a/attached_assets/IMG_0272_1754336435402.png b/attached_assets/IMG_0272_1754336435402.png deleted file mode 100644 index 08ec672..0000000 Binary files a/attached_assets/IMG_0272_1754336435402.png and /dev/null differ diff --git a/client/src/App.tsx b/client/src/App.tsx index 47f094e..57750f6 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -5,8 +5,6 @@ import { Toaster } from "@/components/ui/toaster"; import { TooltipProvider } from "@/components/ui/tooltip"; import Home from "@/pages/home"; import VideoPage from "@/pages/video"; -import { PlaylistsPage } from "@/pages/playlists"; -import { FavoritesPage } from "@/pages/favorites"; import NotFound from "@/pages/not-found"; function Router() { @@ -14,8 +12,6 @@ function Router() { - - ); diff --git a/client/src/components/navigation.tsx b/client/src/components/navigation.tsx deleted file mode 100644 index 911707e..0000000 --- a/client/src/components/navigation.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { Link, useLocation } from "wouter"; -import { Button } from "@/components/ui/button"; -import { Home, List, Heart, User } from "lucide-react"; - -export function Navigation() { - const [location] = useLocation(); - - const navItems = [ - { path: "/", label: "Home", icon: Home }, - { path: "/playlists", label: "Playlists", icon: List }, - { path: "/favorites", label: "Favorites", icon: Heart }, - ]; - - return ( - - - - - - - VideoStream - - - - - {navItems.map((item) => { - const Icon = item.icon; - const isActive = location === item.path; - - return ( - - - - {item.label} - - - ); - })} - - - - - - - Demo User - - - - - {/* Mobile navigation */} - - - {navItems.map((item) => { - const Icon = item.icon; - const isActive = location === item.path; - - return ( - - - - {item.label} - - - ); - })} - - - - - ); -} \ No newline at end of file diff --git a/client/src/components/thumbnail-generator.tsx b/client/src/components/thumbnail-generator.tsx deleted file mode 100644 index b554cec..0000000 --- a/client/src/components/thumbnail-generator.tsx +++ /dev/null @@ -1,212 +0,0 @@ -import React, { useState } from "react"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Download, RefreshCw, Trash2 } from "lucide-react"; -import { type Video } from "@shared/schema"; - -interface ThumbnailGeneratorProps { - video: Video; - onThumbnailGenerated?: (thumbnailUrl: string) => void; -} - -export default function ThumbnailGenerator({ video, onThumbnailGenerated }: ThumbnailGeneratorProps) { - const [isGenerating, setIsGenerating] = useState(false); - const [customTime, setCustomTime] = useState("5"); - const [generatedThumbnails, setGeneratedThumbnails] = useState>([]); - - const generateThumbnail = async (timestamp: string) => { - setIsGenerating(true); - try { - const response = await fetch(`/api/thumbnails/${video.id}/generate`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - timestamps: [timestamp] - }), - }); - - if (response.ok) { - const data = await response.json(); - if (data.thumbnails && data.thumbnails.length > 0) { - const newThumbnail = data.thumbnails[0]; - setGeneratedThumbnails(prev => [...prev, newThumbnail]); - - if (onThumbnailGenerated) { - onThumbnailGenerated(newThumbnail.url); - } - } - } - } catch (error) { - console.error('Error generating thumbnail:', error); - } finally { - setIsGenerating(false); - } - }; - - const generateMultipleThumbnails = async () => { - setIsGenerating(true); - try { - const timestamps = ["5", "15", "30", "60", "90"]; - const response = await fetch(`/api/thumbnails/${video.id}/generate`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - timestamps - }), - }); - - if (response.ok) { - const data = await response.json(); - setGeneratedThumbnails(data.thumbnails || []); - } - } catch (error) { - console.error('Error generating thumbnails:', error); - } finally { - setIsGenerating(false); - } - }; - - const loadExistingThumbnails = async () => { - try { - const response = await fetch(`/api/thumbnails/${video.id}`); - if (response.ok) { - const data = await response.json(); - setGeneratedThumbnails(data.thumbnails || []); - } - } catch (error) { - console.error('Error loading thumbnails:', error); - } - }; - - React.useEffect(() => { - loadExistingThumbnails(); - }, [video.id]); - - return ( - - - - - Ustvarjanje Thumbnail Slik - - - Generirajte thumbnail slike iz različnih trenutkov videa "{video.title}" - - - - {/* Quick generate buttons */} - - Hitro generiranje - - - {isGenerating ? "Generiranje..." : "Generiraj 5 slik (5s, 15s, 30s, 60s, 90s)"} - - - - - {/* Custom time input */} - - Določi čas - - setCustomTime(e.target.value)} - placeholder="Sekunde (npr. 30)" - min="0" - max={video.duration || 300} - /> - generateThumbnail(customTime)} - disabled={isGenerating} - variant="outline" - > - Generiraj - - - - Vnesite čas v sekundah (0 - {video.duration || 300}s) - - - - {/* Generated thumbnails grid */} - {generatedThumbnails.length > 0 && ( - - Generirane slike - - {generatedThumbnails.map((thumbnail, index) => ( - - - - - - {thumbnail.timestamp}s - - - - { - const link = document.createElement('a'); - link.href = thumbnail.url; - link.download = `thumbnail-${video.id}-${thumbnail.timestamp}s.jpg`; - link.click(); - }} - > - - - { - if (onThumbnailGenerated) { - onThumbnailGenerated(thumbnail.url); - } - }} - > - ✓ - - - - - ))} - - - )} - - {/* Instructions */} - - Navodila: - - Kliknite "Generiraj 5 slik" za hitro ustvarjanje slik iz različnih trenutkov - Uporabite "Določi čas" za ustvarjanje slike iz določene sekunde - Kliknite ✓ da nastavite sliko kot glavno thumbnail - Kliknite ⬇ da prenesete sliko na svoj računalnik - - - - - ); -} \ No newline at end of file diff --git a/client/src/components/video-actions.tsx b/client/src/components/video-actions.tsx deleted file mode 100644 index 60fae23..0000000 --- a/client/src/components/video-actions.tsx +++ /dev/null @@ -1,133 +0,0 @@ -import { useState } from "react"; -import { Button } from "@/components/ui/button"; -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; -import { Card, CardContent } from "@/components/ui/card"; -import { Heart, Plus, List, Check } from "lucide-react"; -import { useFavorites } from "@/hooks/use-favorites"; -import { usePlaylists } from "@/hooks/use-playlists"; -import type { Video, Playlist } from "@shared/schema"; - -interface VideoActionsProps { - video: Video; - showLabel?: boolean; -} - -export function VideoActions({ video, showLabel = false }: VideoActionsProps) { - const [isPlaylistDialogOpen, setIsPlaylistDialogOpen] = useState(false); - const { useIsFavorited, addToFavorites, removeFromFavorites } = useFavorites(); - const { useUserPlaylists, addVideoToPlaylist } = usePlaylists(); - - const { data: favoriteStatus } = useIsFavorited(video.id); - const isFavorited = favoriteStatus?.isFavorited || false; - - const { data: playlists } = useUserPlaylists(); - const playlistsArray = Array.isArray(playlists) ? playlists : []; - - const handleFavoriteToggle = () => { - if (isFavorited) { - removeFromFavorites(video.id); - } else { - addToFavorites(video.id); - } - }; - - const handleAddToPlaylist = (playlistId: string) => { - addVideoToPlaylist({ playlistId, videoId: video.id }); - setIsPlaylistDialogOpen(false); - }; - - return ( - - {/* Favorite Button */} - - - {showLabel && ( - - {isFavorited ? "Favorited" : "Add to Favorites"} - - )} - - - {/* Add to Playlist Button */} - - - - - {showLabel && Add to Playlist} - - - - - Add to Playlist - - - - Select a playlist to add "{video.title}" to: - - - {playlistsArray.length > 0 ? ( - - {playlistsArray.map((playlist: Playlist) => ( - handleAddToPlaylist(playlist.id)} - data-testid={`card-playlist-option-${playlist.id}`} - > - - - - {playlist.name} - {playlist.description && ( - - {playlist.description} - - )} - - - - - - ))} - - ) : ( - - - - You don't have any playlists yet. - - { - setIsPlaylistDialogOpen(false); - // You could navigate to /playlists here - }} - data-testid="button-create-first-playlist-from-video" - > - Create Your First Playlist - - - )} - - - - - ); -} \ No newline at end of file diff --git a/client/src/components/video-card.tsx b/client/src/components/video-card.tsx index b29d30b..6a29da3 100644 --- a/client/src/components/video-card.tsx +++ b/client/src/components/video-card.tsx @@ -1,7 +1,6 @@ import { Play, Share2 } from "lucide-react"; import { type Video } from "@shared/schema"; import { Button } from "@/components/ui/button"; -import { VideoActions } from "@/components/video-actions"; interface VideoCardProps { video: Video; @@ -62,21 +61,6 @@ export default function VideoCard({ video, onClick, onShare }: VideoCardProps) { alt={video.title} className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" data-testid={`img-thumbnail-${video.id}`} - onLoad={() => { - console.log('Thumbnail loaded successfully:', video.thumbnailUrl); - }} - onError={(e) => { - console.error('Thumbnail failed to load:', video.thumbnailUrl); - // Use a data URL SVG as fallback - const target = e.target as HTMLImageElement; - const fallbackSvg = ` - - - Thumbnail Error - - `; - target.src = 'data:image/svg+xml;base64,' + btoa(fallbackSvg); - }} /> @@ -122,20 +106,17 @@ export default function VideoCard({ video, onClick, onShare }: VideoCardProps) { {formatDate(video.createdAt)} - - - {onShare && ( - - - - )} - + {onShare && ( + + + + )} diff --git a/client/src/components/video-modal.tsx b/client/src/components/video-modal.tsx index 28fa35c..cffb1c6 100644 --- a/client/src/components/video-modal.tsx +++ b/client/src/components/video-modal.tsx @@ -1,11 +1,9 @@ -import { useEffect, useRef, useState } from "react"; -import { X, Settings } from "lucide-react"; +import { useEffect, useRef } from "react"; +import { X } from "lucide-react"; import { type Video } from "@shared/schema"; import { Button } from "@/components/ui/button"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { apiRequest } from "@/lib/queryClient"; import Hls from "hls.js"; -import ThumbnailGenerator from "./thumbnail-generator"; interface VideoModalProps { video: Video | null; @@ -49,7 +47,6 @@ function formatDate(date: Date | string): string { export default function VideoModal({ video, isOpen, onClose }: VideoModalProps) { const videoRef = useRef(null); const hlsRef = useRef(null); - const [activeTab, setActiveTab] = useState("video"); useEffect(() => { const handleEscape = (e: KeyboardEvent) => { @@ -71,14 +68,74 @@ export default function VideoModal({ video, isOpen, onClose }: VideoModalProps) }; }, [isOpen, onClose]); - // Since we're now using iframe embeds for Bunny.net videos, we don't need HLS.js + // Initialize HLS when video is available useEffect(() => { - // Just log for debugging - if (isOpen && video) { - console.log('Video modal opened with:', video.videoUrl); + if (isOpen && video && videoRef.current) { + const videoElement = videoRef.current; + + // Clean up previous HLS instance + if (hlsRef.current) { + hlsRef.current.destroy(); + hlsRef.current = null; + } + + const videoUrl = video.videoUrl; + console.log('Loading video:', videoUrl); + + // Check if the video URL is HLS (.m3u8) + if (videoUrl.includes('.m3u8')) { + if (Hls.isSupported()) { + // Use HLS.js for browsers that don't support HLS natively + const hls = new Hls({ + debug: true, + enableWorker: false, + lowLatencyMode: true, + backBufferLength: 90 + }); + + hls.loadSource(videoUrl); + hls.attachMedia(videoElement); + + hls.on(Hls.Events.MANIFEST_PARSED, () => { + console.log('HLS manifest loaded successfully'); + }); + + hls.on(Hls.Events.ERROR, (event, data) => { + console.error('HLS error:', data); + if (data.fatal) { + switch (data.type) { + case Hls.ErrorTypes.NETWORK_ERROR: + console.log('Network error, trying to recover...'); + hls.startLoad(); + break; + case Hls.ErrorTypes.MEDIA_ERROR: + console.log('Media error, trying to recover...'); + hls.recoverMediaError(); + break; + default: + console.log('Fatal error, destroying HLS instance...'); + hls.destroy(); + break; + } + } + }); + + hlsRef.current = hls; + } else if (videoElement.canPlayType('application/vnd.apple.mpegurl')) { + // For Safari that supports HLS natively + videoElement.src = videoUrl; + console.log('Using native HLS support'); + } else { + console.error('HLS is not supported in this browser'); + } + } else { + // For regular MP4 videos + videoElement.src = videoUrl; + console.log('Using native video support for MP4'); + } } - - // Cleanup if needed + + // Cleanup when modal closes return () => { if (hlsRef.current) { hlsRef.current.destroy(); @@ -127,21 +184,21 @@ export default function VideoModal({ video, isOpen, onClose }: VideoModalProps) + frameBorder="0" + onLoad={handleVideoPlay} + data-testid="video-iframe" + /> ) : ( Your browser does not support the video tag. diff --git a/client/src/hooks/use-favorites.ts b/client/src/hooks/use-favorites.ts deleted file mode 100644 index e495006..0000000 --- a/client/src/hooks/use-favorites.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { apiRequest } from "@/lib/queryClient"; -import { useToast } from "@/hooks/use-toast"; - -const CURRENT_USER_ID = "5311f8eb-aba2-4f58-96bf-0ca99fe5907c"; // Test user from database - -export function useFavorites() { - const queryClient = useQueryClient(); - const { toast } = useToast(); - - // Check if video is favorited - const useIsFavorited = (videoId: string) => { - return useQuery({ - queryKey: ["/api/favorites", CURRENT_USER_ID, videoId], - queryFn: async () => { - const response = await fetch(`/api/favorites/${CURRENT_USER_ID}/${videoId}`); - if (!response.ok) throw new Error('Failed to check favorite status'); - return response.json(); - }, - enabled: !!videoId, - }); - }; - - // Add to favorites - const addToFavoritesMutation = useMutation({ - mutationFn: (videoId: string) => - apiRequest("/api/favorites", "POST", { - userId: CURRENT_USER_ID, - videoId, - }), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["/api/favorites"] }); - toast({ title: "Added to favorites!" }); - }, - onError: (error: any) => { - if (error.message?.includes("already in favorites")) { - toast({ title: "Already in favorites", variant: "destructive" }); - } else { - toast({ title: "Failed to add to favorites", variant: "destructive" }); - } - }, - }); - - // Remove from favorites - const removeFromFavoritesMutation = useMutation({ - mutationFn: (videoId: string) => - apiRequest(`/api/favorites/${CURRENT_USER_ID}/${videoId}`, "DELETE"), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["/api/favorites"] }); - toast({ title: "Removed from favorites" }); - }, - onError: () => { - toast({ title: "Failed to remove from favorites", variant: "destructive" }); - }, - }); - - return { - useIsFavorited, - addToFavorites: addToFavoritesMutation.mutate, - removeFromFavorites: removeFromFavoritesMutation.mutate, - isAddingToFavorites: addToFavoritesMutation.isPending, - isRemovingFromFavorites: removeFromFavoritesMutation.isPending, - }; -} \ No newline at end of file diff --git a/client/src/hooks/use-playlists.ts b/client/src/hooks/use-playlists.ts deleted file mode 100644 index a1e36b6..0000000 --- a/client/src/hooks/use-playlists.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { apiRequest } from "@/lib/queryClient"; -import { useToast } from "@/hooks/use-toast"; -import type { Playlist, InsertPlaylist } from "@shared/schema"; - -const CURRENT_USER_ID = "5311f8eb-aba2-4f58-96bf-0ca99fe5907c"; // Test user from database - -export function usePlaylists() { - const queryClient = useQueryClient(); - const { toast } = useToast(); - - // Get user playlists - const useUserPlaylists = () => { - return useQuery({ - queryKey: ["/api/playlists", CURRENT_USER_ID], - }); - }; - - // Get single playlist - const usePlaylist = (playlistId: string) => { - return useQuery({ - queryKey: ["/api/playlists", playlistId], - enabled: !!playlistId, - }); - }; - - // Get playlist videos - const usePlaylistVideos = (playlistId: string) => { - return useQuery({ - queryKey: ["/api/playlists", playlistId, "videos"], - enabled: !!playlistId, - }); - }; - - // Add video to playlist - const addVideoToPlaylistMutation = useMutation({ - mutationFn: ({ playlistId, videoId }: { playlistId: string; videoId: string }) => - apiRequest(`/api/playlists/${playlistId}/videos`, "POST", { - videoId, - position: 0, - }), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["/api/playlists"] }); - toast({ title: "Added to playlist!" }); - }, - onError: (error: any) => { - if (error.message?.includes("already in playlist")) { - toast({ title: "Video is already in this playlist", variant: "destructive" }); - } else { - toast({ title: "Failed to add to playlist", variant: "destructive" }); - } - }, - }); - - // Remove video from playlist - const removeVideoFromPlaylistMutation = useMutation({ - mutationFn: ({ playlistId, videoId }: { playlistId: string; videoId: string }) => - apiRequest(`/api/playlists/${playlistId}/videos/${videoId}`, "DELETE"), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["/api/playlists"] }); - toast({ title: "Removed from playlist" }); - }, - onError: () => { - toast({ title: "Failed to remove from playlist", variant: "destructive" }); - }, - }); - - return { - useUserPlaylists, - usePlaylist, - usePlaylistVideos, - addVideoToPlaylist: addVideoToPlaylistMutation.mutate, - removeVideoFromPlaylist: removeVideoFromPlaylistMutation.mutate, - isAddingToPlaylist: addVideoToPlaylistMutation.isPending, - isRemovingFromPlaylist: removeVideoFromPlaylistMutation.isPending, - }; -} \ No newline at end of file diff --git a/client/src/pages/favorites.tsx b/client/src/pages/favorites.tsx deleted file mode 100644 index 8cfaeaf..0000000 --- a/client/src/pages/favorites.tsx +++ /dev/null @@ -1,147 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { apiRequest } from "@/lib/queryClient"; -import { Card, CardContent } from "@/components/ui/card"; -import { Heart, Clock, Play } from "lucide-react"; -import VideoModal from "@/components/video-modal"; -import { useState } from "react"; -import { Navigation } from "@/components/navigation"; -import type { UserFavorite, Video } from "@shared/schema"; - -const CURRENT_USER_ID = "test-user-123"; // For demo purposes - -export function FavoritesPage() { - const [selectedVideo, setSelectedVideo] = useState(null); - - const { data: favorites, isLoading } = useQuery({ - queryKey: ["/api/favorites", CURRENT_USER_ID], - }); - - const formatDuration = (seconds: number) => { - const minutes = Math.floor(seconds / 60); - const remainingSeconds = seconds % 60; - return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`; - }; - - const formatViews = (views: number) => { - if (views >= 1000000) { - return `${(views / 1000000).toFixed(1)}M views`; - } else if (views >= 1000) { - return `${(views / 1000).toFixed(1)}K views`; - } - return `${views} views`; - }; - - if (isLoading) { - return ( - - - {[...Array(8)].map((_, i) => ( - - - - - - - - ))} - - - ); - } - - return ( - - - - - My Favorites - - Videos you've marked as favorites - - - - {favorites && Array.isArray(favorites) && favorites.length === 0 ? ( - - - - No favorites yet - - - Start adding videos to your favorites by clicking the heart icon on any video - - - ) : ( - - {Array.isArray(favorites) && favorites.map((favorite: UserFavorite & { video: Video }) => ( - setSelectedVideo(favorite.video)} - data-testid={`card-favorite-video-${favorite.videoId}`} - > - - - - {/* Play button overlay */} - - - - - - - {/* Duration badge */} - - {formatDuration(favorite.video.duration)} - - - {/* Favorite indicator */} - - - - - - - - {favorite.video.title} - - - - - {formatViews(favorite.video.views)} - - • - - - - Added {new Date(favorite.createdAt).toLocaleDateString()} - - - - - {favorite.video.description && ( - - {favorite.video.description} - - )} - - - ))} - - )} - - {selectedVideo && ( - setSelectedVideo(null)} - /> - )} - - - ); -} \ No newline at end of file diff --git a/client/src/pages/home.tsx b/client/src/pages/home.tsx index b4d30eb..ac6a8d0 100644 --- a/client/src/pages/home.tsx +++ b/client/src/pages/home.tsx @@ -3,7 +3,6 @@ import { useQuery } from "@tanstack/react-query"; import { type Video } from "@shared/schema"; import SearchHeader from "@/components/search-header"; import VideoGrid from "@/components/video-grid"; -import { Navigation } from "@/components/navigation"; interface VideosResponse { videos: Video[]; @@ -92,7 +91,6 @@ export default function Home() { return ( - (null); - - const { data: playlists, isLoading } = useQuery({ - queryKey: ["/api/playlists", CURRENT_USER_ID], - }); - - const createPlaylistMutation = useMutation({ - mutationFn: (data: { name: string; description?: string; isPublic: boolean }) => - apiRequest("/api/playlists", "POST", { - ...data, - userId: CURRENT_USER_ID, - }), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["/api/playlists"] }); - setIsCreateDialogOpen(false); - toast({ title: "Playlist created successfully!" }); - }, - onError: () => { - toast({ title: "Failed to create playlist", variant: "destructive" }); - }, - }); - - const updatePlaylistMutation = useMutation({ - mutationFn: ({ id, ...data }: { id: string; name: string; description?: string; isPublic: boolean }) => - apiRequest(`/api/playlists/${id}?userId=${CURRENT_USER_ID}`, "PUT", data), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["/api/playlists"] }); - setEditingPlaylist(null); - toast({ title: "Playlist updated successfully!" }); - }, - onError: () => { - toast({ title: "Failed to update playlist", variant: "destructive" }); - }, - }); - - const deletePlaylistMutation = useMutation({ - mutationFn: (id: string) => - apiRequest(`/api/playlists/${id}?userId=${CURRENT_USER_ID}`, "DELETE"), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["/api/playlists"] }); - toast({ title: "Playlist deleted successfully!" }); - }, - onError: () => { - toast({ title: "Failed to delete playlist", variant: "destructive" }); - }, - }); - - const handleCreatePlaylist = (e: React.FormEvent) => { - e.preventDefault(); - const formData = new FormData(e.currentTarget); - createPlaylistMutation.mutate({ - name: formData.get("name") as string, - description: formData.get("description") as string, - isPublic: formData.get("isPublic") === "on", - }); - }; - - const handleUpdatePlaylist = (e: React.FormEvent) => { - e.preventDefault(); - if (!editingPlaylist) return; - - const formData = new FormData(e.currentTarget); - updatePlaylistMutation.mutate({ - id: editingPlaylist.id, - name: formData.get("name") as string, - description: formData.get("description") as string, - isPublic: formData.get("isPublic") === "on", - }); - }; - - if (isLoading) { - return ( - - - {[...Array(6)].map((_, i) => ( - - - - - - - - - - - ))} - - - ); - } - - return ( - - - - - - My Playlists - - Organize your favorite videos into custom playlists - - - - - - - - Create Playlist - - - - - Create New Playlist - - - - - Playlist Name - - - - - - Description (optional) - - - - - - - Make this playlist public - - - - setIsCreateDialogOpen(false)} - data-testid="button-cancel" - > - Cancel - - - {createPlaylistMutation.isPending ? "Creating..." : "Create"} - - - - - - - - {playlists && Array.isArray(playlists) && playlists.length === 0 ? ( - - - - No playlists yet - - - Create your first playlist to organize your favorite videos - - setIsCreateDialogOpen(true)} data-testid="button-create-first-playlist"> - - Create Your First Playlist - - - ) : ( - - {Array.isArray(playlists) && playlists.map((playlist: Playlist) => ( - - - - - - {playlist.name} - - - - {playlist.isPublic ? ( - <> - - Public - > - ) : ( - <> - - Private - > - )} - - - - - setEditingPlaylist(playlist)} - data-testid={`button-edit-playlist-${playlist.id}`} - > - - - deletePlaylistMutation.mutate(playlist.id)} - data-testid={`button-delete-playlist-${playlist.id}`} - > - - - - - - - {playlist.description && ( - - {playlist.description} - - )} - - Created {new Date(playlist.createdAt).toLocaleDateString()} - - - - ))} - - )} - - {/* Edit Playlist Dialog */} - setEditingPlaylist(null)}> - - - Edit Playlist - - {editingPlaylist && ( - - - - Playlist Name - - - - - - Description (optional) - - - - - - - Make this playlist public - - - - setEditingPlaylist(null)} - data-testid="button-cancel-edit" - > - Cancel - - - {updatePlaylistMutation.isPending ? "Saving..." : "Save Changes"} - - - - )} - - - - - ); -} \ No newline at end of file diff --git a/client/src/pages/video.tsx b/client/src/pages/video.tsx index 6addfdd..b6dc500 100644 --- a/client/src/pages/video.tsx +++ b/client/src/pages/video.tsx @@ -93,13 +93,10 @@ export default function VideoPage() { const shareUrl = `${window.location.origin}/video/${video.id}`; - // Use server proxy endpoint for reliable thumbnail access in social sharing - const publicThumbnailUrl = `${window.location.origin}/thumbnail/${video.id}`; - // Open Graph tags for Facebook updateMeta('og:title', video.title); updateMeta('og:description', video.description || `Oglej si ta video na VideoStream`); - updateMeta('og:image', publicThumbnailUrl); + updateMeta('og:image', video.thumbnailUrl); updateMeta('og:image:width', '1200'); updateMeta('og:image:height', '630'); updateMeta('og:image:type', 'image/jpeg'); @@ -114,7 +111,7 @@ export default function VideoPage() { updateMeta('twitter:card', 'summary_large_image', false); updateMeta('twitter:title', video.title, false); updateMeta('twitter:description', video.description || `Oglej si ta video na VideoStream`, false); - updateMeta('twitter:image', publicThumbnailUrl, false); + updateMeta('twitter:image', video.thumbnailUrl, false); } }, [video]); diff --git a/package-lock.json b/package-lock.json index a193618..90d45c6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,7 +40,6 @@ "@radix-ui/react-toggle-group": "^1.1.3", "@radix-ui/react-tooltip": "^1.2.0", "@tanstack/react-query": "^5.60.5", - "@types/memoizee": "^0.4.12", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -55,10 +54,8 @@ "hls.js": "^1.6.7", "input-otp": "^1.4.2", "lucide-react": "^0.453.0", - "memoizee": "^0.4.17", "memorystore": "^1.6.7", "next-themes": "^0.4.6", - "openid-client": "^6.6.2", "passport": "^0.7.0", "passport-local": "^1.0.0", "react": "^18.3.1", @@ -3468,12 +3465,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/memoizee": { - "version": "0.4.12", - "resolved": "https://registry.npmjs.org/@types/memoizee/-/memoizee-0.4.12.tgz", - "integrity": "sha512-EdtpwNYNhe3kZ+4TlXj/++pvBoU0KdrAICMzgI7vjWgu9sIvvUhu9XR8Ks4L6Wh3sxpZ22wkZR7yCLAqUjnZuQ==", - "license": "MIT" - }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -4121,19 +4112,6 @@ "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "license": "MIT" }, - "node_modules/d": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/d/-/d-1.0.2.tgz", - "integrity": "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==", - "license": "ISC", - "dependencies": { - "es5-ext": "^0.10.64", - "type": "^2.7.2" - }, - "engines": { - "node": ">=0.12" - } - }, "node_modules/d3-array": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", @@ -5032,58 +5010,6 @@ "node": ">= 0.4" } }, - "node_modules/es5-ext": { - "version": "0.10.64", - "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz", - "integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==", - "hasInstallScript": true, - "license": "ISC", - "dependencies": { - "es6-iterator": "^2.0.3", - "es6-symbol": "^3.1.3", - "esniff": "^2.0.1", - "next-tick": "^1.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/es6-iterator": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", - "integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==", - "license": "MIT", - "dependencies": { - "d": "1", - "es5-ext": "^0.10.35", - "es6-symbol": "^3.1.1" - } - }, - "node_modules/es6-symbol": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.4.tgz", - "integrity": "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==", - "license": "ISC", - "dependencies": { - "d": "^1.0.2", - "ext": "^1.7.0" - }, - "engines": { - "node": ">=0.12" - } - }, - "node_modules/es6-weak-map": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/es6-weak-map/-/es6-weak-map-2.0.3.tgz", - "integrity": "sha512-p5um32HOTO1kP+w7PRnB+5lQ43Z6muuMuIMffvDN8ZB4GcnjLBV6zGStpbASIMk4DCAvEaamhe2zhyCb/QXXsA==", - "license": "ISC", - "dependencies": { - "d": "1", - "es5-ext": "^0.10.46", - "es6-iterator": "^2.0.3", - "es6-symbol": "^3.1.1" - } - }, "node_modules/esbuild": { "version": "0.25.0", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.0.tgz", @@ -5154,21 +5080,6 @@ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, - "node_modules/esniff": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz", - "integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==", - "license": "ISC", - "dependencies": { - "d": "^1.0.1", - "es5-ext": "^0.10.62", - "event-emitter": "^0.3.5", - "type": "^2.7.2" - }, - "engines": { - "node": ">=0.10" - } - }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -5178,16 +5089,6 @@ "node": ">= 0.6" } }, - "node_modules/event-emitter": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", - "integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==", - "license": "MIT", - "dependencies": { - "d": "1", - "es5-ext": "~0.10.14" - } - }, "node_modules/eventemitter3": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", @@ -5304,15 +5205,6 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, - "node_modules/ext": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz", - "integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==", - "license": "ISC", - "dependencies": { - "type": "^2.7.2" - } - }, "node_modules/fast-equals": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.2.2.tgz", @@ -5792,12 +5684,6 @@ "node": ">=0.12.0" } }, - "node_modules/is-promise": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz", - "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==", - "license": "MIT" - }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -5828,15 +5714,6 @@ "jiti": "bin/jiti.js" } }, - "node_modules/jose": { - "version": "6.0.12", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.0.12.tgz", - "integrity": "sha512-T8xypXs8CpmiIi78k0E+Lk7T2zlK4zDyg+o1CZ4AkOHgDg98ogdP2BeZ61lTFKFyoEwJ9RgAgN+SdM3iPgNonQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -6163,15 +6040,6 @@ "yallist": "^3.0.2" } }, - "node_modules/lru-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/lru-queue/-/lru-queue-0.1.0.tgz", - "integrity": "sha512-BpdYkt9EvGl8OfWHDQPISVpcl5xZthb+XPsbELj5AQXxIC8IriDZIQYjBJPEm5rS420sjZ0TLEzRcq5KdBhYrQ==", - "license": "MIT", - "dependencies": { - "es5-ext": "~0.10.2" - } - }, "node_modules/lucide-react": { "version": "0.453.0", "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.453.0.tgz", @@ -6198,25 +6066,6 @@ "node": ">= 0.6" } }, - "node_modules/memoizee": { - "version": "0.4.17", - "resolved": "https://registry.npmjs.org/memoizee/-/memoizee-0.4.17.tgz", - "integrity": "sha512-DGqD7Hjpi/1or4F/aYAspXKNm5Yili0QDAFAY4QYvpqpgiY6+1jOfqpmByzjxbWd/T9mChbCArXAbDAsTm5oXA==", - "license": "ISC", - "dependencies": { - "d": "^1.0.2", - "es5-ext": "^0.10.64", - "es6-weak-map": "^2.0.3", - "event-emitter": "^0.3.5", - "is-promise": "^2.2.2", - "lru-queue": "^0.1.0", - "next-tick": "^1.1.0", - "timers-ext": "^0.1.7" - }, - "engines": { - "node": ">=0.12" - } - }, "node_modules/memorystore": { "version": "1.6.7", "resolved": "https://registry.npmjs.org/memorystore/-/memorystore-1.6.7.tgz", @@ -6417,12 +6266,6 @@ "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, - "node_modules/next-tick": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz", - "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==", - "license": "ISC" - }, "node_modules/node-gyp-build": { "version": "4.8.3", "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.3.tgz", @@ -6461,15 +6304,6 @@ "node": ">=0.10.0" } }, - "node_modules/oauth4webapi": { - "version": "3.6.1", - "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.6.1.tgz", - "integrity": "sha512-b39+drVyA4aNUptFOhkkmGWnG/BE7dT29SW/8PVYElqp7j/DBqzm5SS1G+MUD07XlTcBOAG+6Cb/35Cx2kHIuQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -6527,19 +6361,6 @@ "node": ">= 0.8" } }, - "node_modules/openid-client": { - "version": "6.6.2", - "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.6.2.tgz", - "integrity": "sha512-Xya5TNMnnZuTM6DbHdB4q0S3ig2NTAELnii/ASie1xDEr8iiB8zZbO871OWBdrw++sd3hW6bqWjgcmSy1RTWHA==", - "license": "MIT", - "dependencies": { - "jose": "^6.0.11", - "oauth4webapi": "^3.5.4" - }, - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -7910,19 +7731,6 @@ "node": ">=0.8" } }, - "node_modules/timers-ext": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/timers-ext/-/timers-ext-0.1.8.tgz", - "integrity": "sha512-wFH7+SEAcKfJpfLPkrgMPvvwnEtj8W4IurvEyrKsDleXnKLCDw71w8jltvfLa8Rm4qQxxT4jmDBYbJG/z7qoww==", - "license": "ISC", - "dependencies": { - "es5-ext": "^0.10.64", - "next-tick": "^1.1.0" - }, - "engines": { - "node": ">=0.12" - } - }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", @@ -8438,12 +8246,6 @@ "url": "https://github.com/sponsors/Wombosvideo" } }, - "node_modules/type": { - "version": "2.7.3", - "resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz", - "integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==", - "license": "ISC" - }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", diff --git a/package.json b/package.json index bad8db2..e83a040 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,6 @@ "@radix-ui/react-toggle-group": "^1.1.3", "@radix-ui/react-tooltip": "^1.2.0", "@tanstack/react-query": "^5.60.5", - "@types/memoizee": "^0.4.12", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -57,10 +56,8 @@ "hls.js": "^1.6.7", "input-otp": "^1.4.2", "lucide-react": "^0.453.0", - "memoizee": "^0.4.17", "memorystore": "^1.6.7", "next-themes": "^0.4.6", - "openid-client": "^6.6.2", "passport": "^0.7.0", "passport-local": "^1.0.0", "react": "^18.3.1", diff --git a/replit.md b/replit.md index 33bb841..6a3df67 100644 --- a/replit.md +++ b/replit.md @@ -10,36 +10,18 @@ Preferred communication style: Simple, everyday language. ## Recent Updates (August 4, 2025) -### Video Streaming Implementation ✅ -- Successfully implemented Bunny.net iframe embed for private video library access -- Videos stream reliably using https://iframe.mediadelivery.net/embed/ approach -- Added fullscreen capabilities and proper video controls -- Resolved authentication issues with private video library (ID: 476412) - -### Advanced Thumbnail Generation System ✅ -- Implemented FFmpeg and ImageMagick for extracting real frames from videos -- Added ability to specify exact timestamp for thumbnail generation (e.g., ?t=30) -- Created comprehensive ThumbnailGenerator class with multiple extraction options -- Added API endpoints for generating multiple thumbnails at once -- Built React component for easy thumbnail selection and generation -- Fallback to attractive SVG thumbnails when video extraction fails -- Cache system for optimized performance and reduced server load - -### Video Sharing Functionality ✅ -- Added comprehensive share functionality for social media platforms +### Video Sharing Functionality +- Added comprehensive share functionality for social media platforms (Facebook, Twitter, WhatsApp) - Created dedicated video pages with shareable URLs (/video/:id) - Implemented Open Graph meta tags for proper social media previews - Added share buttons on video cards and in video modal -- SVG thumbnails ensure consistent sharing experience across platforms +- Created ShareModal component with copy-to-clipboard functionality -### User Management & Social Features ⚠️ (In Progress) -- Implemented PostgreSQL database schema for users, playlists, and favorites -- Created complete API routes for user registration, playlists, and favorites management -- Built frontend pages for playlists (/playlists) and favorites (/favorites) with React components -- Added navigation component with Home, Playlists, and Favorites sections -- Created video action buttons (favorite/playlist) integrated into video cards -- Developed custom hooks for favorites and playlists management -- Note: Database integration pending - tables need to be created and storage implementation completed +### Private Video Access +- Resolved Bunny.net private video streaming issues using iframe embed approach +- Implemented iframe.mediadelivery.net integration for private video libraries +- Videos now properly stream using Bunny.net's secure embed system +- Maintained thumbnail display from CDN while using iframe for video playback ## System Architecture diff --git a/server/bunny.ts b/server/bunny.ts index d285a19..b4e46a8 100644 --- a/server/bunny.ts +++ b/server/bunny.ts @@ -1,5 +1,4 @@ import { type Video, type InsertVideo } from "@shared/schema"; -import crypto from 'crypto'; interface BunnyVideo { guid: string; @@ -23,13 +22,11 @@ export class BunnyService { private apiKey: string; private libraryId: string; private hostname: string; - private securityKey: string; constructor() { this.apiKey = process.env.BUNNY_API_KEY!; this.libraryId = process.env.BUNNY_LIBRARY_ID!; this.hostname = process.env.BUNNY_HOSTNAME!; - this.securityKey = process.env.BUNNY_SECURITY_KEY || ''; // CDN security key for signing URLs if (!this.apiKey || !this.libraryId || !this.hostname) { throw new Error("Missing Bunny.net configuration"); @@ -54,41 +51,14 @@ export class BunnyService { return response.json(); } - private generateSignedUrl(path: string, expiryHours: number = 1): string { - if (!this.securityKey) { - // If no security key, return iframe embed as fallback - const videoId = path.split('/')[1]; - return `https://iframe.mediadelivery.net/embed/${this.libraryId}/${videoId}?controls=true&autoplay=false`; - } - - const expireTimestamp = Math.floor(Date.now() / 1000) + (expiryHours * 3600); - - // Bunny.net uses SHA256 for token generation according to docs - // hashableBase = securityKey + signaturePath + expires + userIp + parameterData - const hashableBase = `${this.securityKey}${path}${expireTimestamp}`; - - const hash = crypto.createHash('sha256').update(hashableBase).digest(); - const token = Buffer.from(hash).toString('base64') - .replace(/\n/g, '') - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=/g, ''); - - return `https://${this.hostname}${path}?token=${token}&expires=${expireTimestamp}`; - } - - // Public method for generating signed URLs for sharing - generatePublicSignedUrl(path: string, expiryHours: number = 1): string { - return this.generateSignedUrl(path, expiryHours); - } - private bunnyVideoToVideo(bunnyVideo: BunnyVideo): Video { - // Since Bunny.net private library token authentication is not working, - // use iframe embed approach for reliable video playback - const videoUrl = `https://iframe.mediadelivery.net/embed/${this.libraryId}/${bunnyVideo.guid}?controls=true&autoplay=false`; - - // Use proxy endpoint that will try multiple thumbnail sources - const thumbnailUrl = `/thumbnail/${bunnyVideo.guid}`; + // For private videos, we'll generate a video poster frame from the iframe + // This is the best approach for private Bunny.net videos + const thumbnailUrl = `https://iframe.mediadelivery.net/embed/${this.libraryId}/${bunnyVideo.guid}?poster=true`; + + // For private videos, we'll use an iframe embed URL which handles authentication + // Enable controls, allow fullscreen, and ensure player functionality + const videoUrl = `https://iframe.mediadelivery.net/embed/${this.libraryId}/${bunnyVideo.guid}?controls=true&autoplay=false&preload=metadata`; return { id: bunnyVideo.guid, diff --git a/server/db.ts b/server/db.ts deleted file mode 100644 index 66779a9..0000000 --- a/server/db.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Pool, neonConfig } from '@neondatabase/serverless'; -import { drizzle } from 'drizzle-orm/neon-serverless'; -import ws from "ws"; -import * as schema from "@shared/schema"; - -neonConfig.webSocketConstructor = ws; - -if (!process.env.DATABASE_URL) { - throw new Error( - "DATABASE_URL must be set. Did you forget to provision a database?", - ); -} - -export const pool = new Pool({ connectionString: process.env.DATABASE_URL }); -export const db = drizzle({ client: pool, schema }); diff --git a/server/routes.ts b/server/routes.ts index 007d47e..123767e 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -2,11 +2,8 @@ import type { Express } from "express"; import { createServer, type Server } from "http"; import { storage } from "./storage"; import { z } from "zod"; -import { BunnyService } from "./bunny"; -import { ThumbnailGenerator } from "./thumbnail-generator"; export async function registerRoutes(app: Express): Promise { - const thumbnailGenerator = new ThumbnailGenerator(); // Get videos with pagination and filtering app.get("/api/videos", async (req, res) => { try { @@ -120,338 +117,6 @@ export async function registerRoutes(app: Express): Promise { } }); - // Generate real thumbnail from video frame at specific time - app.get("/thumbnail/:videoId", async (req, res) => { - const timeStamp = req.query.t as string || "5"; // Default to 5 seconds - - try { - const { videoId } = req.params; - - // Get video info - const video = await storage.getVideo(videoId); - if (!video) { - return res.status(404).json({ message: "Video not found" }); - } - - // Try to generate real thumbnail from video - const thumbnailPath = await thumbnailGenerator.generateThumbnail({ - videoId, - timeStamp, - width: 400, - height: 225, - quality: 85 - }); - - if (thumbnailPath && require('fs').existsSync(thumbnailPath)) { - // Serve the generated thumbnail - res.setHeader('Content-Type', 'image/jpeg'); - res.setHeader('Cache-Control', 'public, max-age=86400'); - res.setHeader('Access-Control-Allow-Origin', '*'); - return res.sendFile(require('path').resolve(thumbnailPath)); - } else { - // Fallback to SVG if thumbnail generation fails - const displayTitle = video.title.replace('.mp4', '').substring(0, 40); - const duration = video.duration ? Math.floor(video.duration / 60) + ':' + String(video.duration % 60).padStart(2, '0') : ''; - - const svg = ` - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ${displayTitle} - - - ${duration ? ` - ${duration}` : ''} - - - VIDEO - - `; - - res.setHeader('Content-Type', 'image/svg+xml; charset=utf-8'); - res.setHeader('Cache-Control', 'public, max-age=86400'); - res.setHeader('Access-Control-Allow-Origin', '*'); - return res.send(svg); - } - - } catch (error) { - console.error("Error creating thumbnail:", error); - res.status(500).json({ message: "Error generating thumbnail" }); - } - }); - - // API endpoint to generate multiple thumbnails for video preview - app.post("/api/thumbnails/:videoId/generate", async (req, res) => { - try { - const { videoId } = req.params; - const { timestamps = ["5", "15", "30", "60"] } = req.body; - - const video = await storage.getVideo(videoId); - if (!video) { - return res.status(404).json({ message: "Video not found" }); - } - - const thumbnailPaths = await thumbnailGenerator.generateMultipleThumbnails(videoId, timestamps); - - const thumbnails = thumbnailPaths.map((path, index) => ({ - timestamp: timestamps[index], - url: `/thumbnail/${videoId}?t=${timestamps[index]}`, - path: path - })); - - res.json({ thumbnails }); - - } catch (error) { - console.error("Error generating thumbnails:", error); - res.status(500).json({ message: "Error generating thumbnails" }); - } - }); - - // API endpoint to list existing thumbnails for a video - app.get("/api/thumbnails/:videoId", async (req, res) => { - try { - const { videoId } = req.params; - const thumbnailPaths = thumbnailGenerator.listThumbnails(videoId); - - const thumbnails = thumbnailPaths.map(path => { - const filename = require('path').basename(path); - const match = filename.match(new RegExp(`${videoId}_(.+)_\\d+x\\d+\\.jpg`)); - const timestamp = match ? match[1].replace(/-/g, ':') : 'unknown'; - - return { - timestamp, - url: `/thumbnail/${videoId}?t=${timestamp}`, - path: path - }; - }); - - res.json({ thumbnails }); - - } catch (error) { - console.error("Error listing thumbnails:", error); - res.status(500).json({ message: "Error listing thumbnails" }); - } - }); - - // User routes - app.get("/api/users/:id", async (req, res) => { - try { - const user = await storage.getUser(req.params.id); - if (!user) { - return res.status(404).json({ message: "User not found" }); - } - // Don't send password hash - const { passwordHash, ...safeUser } = user; - res.json(safeUser); - } catch (error) { - res.status(500).json({ message: "Failed to fetch user" }); - } - }); - - app.post("/api/users", async (req, res) => { - try { - const userData = req.body; - const user = await storage.createUser(userData); - const { passwordHash, ...safeUser } = user; - res.status(201).json(safeUser); - } catch (error) { - res.status(500).json({ message: "Failed to create user" }); - } - }); - - // Playlist routes - app.get("/api/playlists", async (req, res) => { - try { - const userId = req.query.userId as string; - const limit = parseInt(req.query.limit as string) || 20; - const offset = parseInt(req.query.offset as string) || 0; - - if (!userId) { - return res.status(400).json({ message: "userId is required" }); - } - - const playlists = await storage.getPlaylists(userId, limit, offset); - res.json(playlists); - } catch (error) { - res.status(500).json({ message: "Failed to fetch playlists" }); - } - }); - - app.get("/api/playlists/:id", async (req, res) => { - try { - const userId = req.query.userId as string; - const playlist = await storage.getPlaylist(req.params.id, userId); - if (!playlist) { - return res.status(404).json({ message: "Playlist not found" }); - } - res.json(playlist); - } catch (error) { - res.status(500).json({ message: "Failed to fetch playlist" }); - } - }); - - app.post("/api/playlists", async (req, res) => { - try { - const playlist = await storage.createPlaylist(req.body); - res.status(201).json(playlist); - } catch (error) { - res.status(500).json({ message: "Failed to create playlist" }); - } - }); - - app.put("/api/playlists/:id", async (req, res) => { - try { - const userId = req.query.userId as string; - if (!userId) { - return res.status(400).json({ message: "userId is required" }); - } - - const playlist = await storage.updatePlaylist(req.params.id, req.body, userId); - if (!playlist) { - return res.status(404).json({ message: "Playlist not found or access denied" }); - } - res.json(playlist); - } catch (error) { - res.status(500).json({ message: "Failed to update playlist" }); - } - }); - - app.delete("/api/playlists/:id", async (req, res) => { - try { - const userId = req.query.userId as string; - if (!userId) { - return res.status(400).json({ message: "userId is required" }); - } - - const success = await storage.deletePlaylist(req.params.id, userId); - if (!success) { - return res.status(404).json({ message: "Playlist not found or access denied" }); - } - res.json({ success: true }); - } catch (error) { - res.status(500).json({ message: "Failed to delete playlist" }); - } - }); - - // Playlist videos routes - app.get("/api/playlists/:id/videos", async (req, res) => { - try { - const videos = await storage.getPlaylistVideos(req.params.id); - res.json(videos); - } catch (error) { - res.status(500).json({ message: "Failed to fetch playlist videos" }); - } - }); - - app.post("/api/playlists/:id/videos", async (req, res) => { - try { - const playlistVideo = await storage.addVideoToPlaylist({ - playlistId: req.params.id, - videoId: req.body.videoId, - position: req.body.position || 0 - }); - res.status(201).json(playlistVideo); - } catch (error) { - if (error instanceof Error && error.message.includes("already in playlist")) { - return res.status(409).json({ message: error.message }); - } - res.status(500).json({ message: "Failed to add video to playlist" }); - } - }); - - app.delete("/api/playlists/:playlistId/videos/:videoId", async (req, res) => { - try { - const success = await storage.removeVideoFromPlaylist(req.params.playlistId, req.params.videoId); - if (!success) { - return res.status(404).json({ message: "Video not found in playlist" }); - } - res.json({ success: true }); - } catch (error) { - res.status(500).json({ message: "Failed to remove video from playlist" }); - } - }); - - // Favorites routes - app.get("/api/favorites", async (req, res) => { - try { - const userId = req.query.userId as string; - const limit = parseInt(req.query.limit as string) || 20; - const offset = parseInt(req.query.offset as string) || 0; - - if (!userId) { - return res.status(400).json({ message: "userId is required" }); - } - - const favorites = await storage.getUserFavorites(userId, limit, offset); - res.json(favorites); - } catch (error) { - res.status(500).json({ message: "Failed to fetch favorites" }); - } - }); - - app.post("/api/favorites", async (req, res) => { - try { - const favorite = await storage.addToFavorites({ - userId: req.body.userId, - videoId: req.body.videoId - }); - res.status(201).json(favorite); - } catch (error) { - if (error instanceof Error && error.message.includes("already in favorites")) { - return res.status(409).json({ message: error.message }); - } - res.status(500).json({ message: "Failed to add to favorites" }); - } - }); - - app.delete("/api/favorites/:userId/:videoId", async (req, res) => { - try { - const success = await storage.removeFromFavorites(req.params.userId, req.params.videoId); - if (!success) { - return res.status(404).json({ message: "Favorite not found" }); - } - res.json({ success: true }); - } catch (error) { - res.status(500).json({ message: "Failed to remove from favorites" }); - } - }); - - app.get("/api/favorites/:userId/:videoId", async (req, res) => { - try { - const isFavorited = await storage.isVideoFavorited(req.params.userId, req.params.videoId); - res.json({ isFavorited }); - } catch (error) { - res.status(500).json({ message: "Failed to check favorite status" }); - } - }); - const httpServer = createServer(app); return httpServer; } diff --git a/server/storage.ts b/server/storage.ts index 330c455..fe325d9 100644 --- a/server/storage.ts +++ b/server/storage.ts @@ -1,69 +1,23 @@ -import { - type Video, - type InsertVideo, - type User, - type InsertUser, - type Playlist, - type InsertPlaylist, - type PlaylistVideo, - type InsertPlaylistVideo, - type UserFavorite, - type InsertUserFavorite -} from "@shared/schema"; +import { type Video, type InsertVideo } from "@shared/schema"; import { randomUUID } from "crypto"; import { BunnyService } from "./bunny"; export interface IStorage { - // Video operations getVideos(limit?: number, offset?: number, search?: string, category?: string): Promise; getVideo(id: string): Promise; createVideo(video: InsertVideo): Promise; updateVideoViews(id: string): Promise; getVideoCount(search?: string, category?: string): Promise; - - // User operations - getUser(id: string): Promise; - getUserByEmail(email: string): Promise; - getUserByUsername(username: string): Promise; - createUser(user: InsertUser): Promise; - updateUser(id: string, user: Partial): Promise; - - // Playlist operations - getPlaylists(userId: string, limit?: number, offset?: number): Promise; - getPlaylist(id: string, userId?: string): Promise; - createPlaylist(playlist: InsertPlaylist): Promise; - updatePlaylist(id: string, playlist: Partial, userId: string): Promise; - deletePlaylist(id: string, userId: string): Promise; - - // Playlist video operations - getPlaylistVideos(playlistId: string): Promise<(PlaylistVideo & { video: Video })[]>; - addVideoToPlaylist(playlistVideo: InsertPlaylistVideo): Promise; - removeVideoFromPlaylist(playlistId: string, videoId: string): Promise; - - // Favorites operations - getUserFavorites(userId: string, limit?: number, offset?: number): Promise<(UserFavorite & { video: Video })[]>; - addToFavorites(favorite: InsertUserFavorite): Promise; - removeFromFavorites(userId: string, videoId: string): Promise; - isVideoFavorited(userId: string, videoId: string): Promise; } export class MemStorage implements IStorage { private videos: Map; - private users: Map; - private playlists: Map; - private playlistVideos: Map; - private userFavorites: Map; constructor() { this.videos = new Map(); - this.users = new Map(); - this.playlists = new Map(); - this.playlistVideos = new Map(); - this.userFavorites = new Map(); // Initialize with some sample videos for demonstration // In production, these would be fetched from bunny.net API this.initializeSampleVideos(); - this.initializeSampleUsers(); } private initializeSampleVideos() { @@ -191,222 +145,6 @@ export class MemStorage implements IStorage { const videos = await this.getVideos(1000, 0, search, category); return videos.length; } - - private initializeSampleUsers() { - // Sample user for testing - const sampleUser: User = { - id: "test-user-123", - username: "testuser", - email: "test@example.com", - passwordHash: "hashed_password", - firstName: "Test", - lastName: "User", - avatar: null, - isActive: true, - createdAt: new Date(), - updatedAt: new Date(), - }; - this.users.set(sampleUser.id, sampleUser); - } - - // User operations - async getUser(id: string): Promise { - return this.users.get(id); - } - - async getUserByEmail(email: string): Promise { - for (const user of this.users.values()) { - if (user.email === email) { - return user; - } - } - return undefined; - } - - async getUserByUsername(username: string): Promise { - for (const user of this.users.values()) { - if (user.username === username) { - return user; - } - } - return undefined; - } - - async createUser(userData: InsertUser): Promise { - const user: User = { - id: randomUUID(), - ...userData, - createdAt: new Date(), - updatedAt: new Date(), - }; - this.users.set(user.id, user); - return user; - } - - async updateUser(id: string, userData: Partial): Promise { - const user = this.users.get(id); - if (!user) return undefined; - - const updatedUser: User = { - ...user, - ...userData, - updatedAt: new Date(), - }; - this.users.set(id, updatedUser); - return updatedUser; - } - - // Playlist operations - async getPlaylists(userId: string, limit = 20, offset = 0): Promise { - const userPlaylists = Array.from(this.playlists.values()) - .filter(playlist => playlist.userId === userId) - .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()) - .slice(offset, offset + limit); - return userPlaylists; - } - - async getPlaylist(id: string, userId?: string): Promise { - const playlist = this.playlists.get(id); - if (!playlist) return undefined; - - // If userId is provided, check ownership or public status - if (userId && playlist.userId !== userId && !playlist.isPublic) { - return undefined; - } - - return playlist; - } - - async createPlaylist(playlistData: InsertPlaylist): Promise { - const playlist: Playlist = { - id: randomUUID(), - ...playlistData, - createdAt: new Date(), - updatedAt: new Date(), - }; - this.playlists.set(playlist.id, playlist); - this.playlistVideos.set(playlist.id, []); - return playlist; - } - - async updatePlaylist(id: string, playlistData: Partial, userId: string): Promise { - const playlist = this.playlists.get(id); - if (!playlist || playlist.userId !== userId) return undefined; - - const updatedPlaylist: Playlist = { - ...playlist, - ...playlistData, - updatedAt: new Date(), - }; - this.playlists.set(id, updatedPlaylist); - return updatedPlaylist; - } - - async deletePlaylist(id: string, userId: string): Promise { - const playlist = this.playlists.get(id); - if (!playlist || playlist.userId !== userId) return false; - - this.playlists.delete(id); - this.playlistVideos.delete(id); - return true; - } - - // Playlist video operations - async getPlaylistVideos(playlistId: string): Promise<(PlaylistVideo & { video: Video })[]> { - const videos = this.playlistVideos.get(playlistId) || []; - const result = []; - - for (const playlistVideo of videos) { - const video = this.videos.get(playlistVideo.videoId); - if (video) { - result.push({ ...playlistVideo, video }); - } - } - - return result.sort((a, b) => a.position - b.position); - } - - async addVideoToPlaylist(playlistVideoData: InsertPlaylistVideo): Promise { - const videos = this.playlistVideos.get(playlistVideoData.playlistId) || []; - - // Check if video is already in playlist - const existingIndex = videos.findIndex(v => v.videoId === playlistVideoData.videoId); - if (existingIndex !== -1) { - throw new Error("Video is already in playlist"); - } - - const playlistVideo: PlaylistVideo = { - ...playlistVideoData, - addedAt: new Date(), - }; - - videos.push(playlistVideo); - this.playlistVideos.set(playlistVideoData.playlistId, videos); - return playlistVideo; - } - - async removeVideoFromPlaylist(playlistId: string, videoId: string): Promise { - const videos = this.playlistVideos.get(playlistId) || []; - const filteredVideos = videos.filter(v => v.videoId !== videoId); - - if (filteredVideos.length === videos.length) { - return false; // Video not found - } - - this.playlistVideos.set(playlistId, filteredVideos); - return true; - } - - // Favorites operations - async getUserFavorites(userId: string, limit = 20, offset = 0): Promise<(UserFavorite & { video: Video })[]> { - const favorites = this.userFavorites.get(userId) || []; - const result = []; - - for (const favorite of favorites.slice(offset, offset + limit)) { - const video = this.videos.get(favorite.videoId); - if (video) { - result.push({ ...favorite, video }); - } - } - - return result.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); - } - - async addToFavorites(favoriteData: InsertUserFavorite): Promise { - const favorites = this.userFavorites.get(favoriteData.userId) || []; - - // Check if already favorited - const existing = favorites.find(f => f.videoId === favoriteData.videoId); - if (existing) { - throw new Error("Video is already in favorites"); - } - - const favorite: UserFavorite = { - ...favoriteData, - createdAt: new Date(), - }; - - favorites.push(favorite); - this.userFavorites.set(favoriteData.userId, favorites); - return favorite; - } - - async removeFromFavorites(userId: string, videoId: string): Promise { - const favorites = this.userFavorites.get(userId) || []; - const filteredFavorites = favorites.filter(f => f.videoId !== videoId); - - if (filteredFavorites.length === favorites.length) { - return false; // Favorite not found - } - - this.userFavorites.set(userId, filteredFavorites); - return true; - } - - async isVideoFavorited(userId: string, videoId: string): Promise { - const favorites = this.userFavorites.get(userId) || []; - return favorites.some(f => f.videoId === videoId); - } } // Use Bunny.net storage if API keys are available, otherwise fallback to memory storage @@ -482,291 +220,6 @@ class BunnyStorage implements IStorage { return 0; } } - - // User operations - Not implemented for BunnyStorage (video-only service) - async getUser(id: string): Promise { - throw new Error("User operations are not supported with Bunny.net storage"); - } - - async getUserByEmail(email: string): Promise { - throw new Error("User operations are not supported with Bunny.net storage"); - } - - async getUserByUsername(username: string): Promise { - throw new Error("User operations are not supported with Bunny.net storage"); - } - - async createUser(user: InsertUser): Promise { - throw new Error("User operations are not supported with Bunny.net storage"); - } - - async updateUser(id: string, user: Partial): Promise { - throw new Error("User operations are not supported with Bunny.net storage"); - } - - // Playlist operations - Not implemented for BunnyStorage (video-only service) - async getPlaylists(userId: string, limit?: number, offset?: number): Promise { - throw new Error("Playlist operations are not supported with Bunny.net storage"); - } - - async getPlaylist(id: string, userId?: string): Promise { - throw new Error("Playlist operations are not supported with Bunny.net storage"); - } - - async createPlaylist(playlist: InsertPlaylist): Promise { - throw new Error("Playlist operations are not supported with Bunny.net storage"); - } - - async updatePlaylist(id: string, playlist: Partial, userId: string): Promise { - throw new Error("Playlist operations are not supported with Bunny.net storage"); - } - - async deletePlaylist(id: string, userId: string): Promise { - throw new Error("Playlist operations are not supported with Bunny.net storage"); - } - - async getPlaylistVideos(playlistId: string): Promise<(PlaylistVideo & { video: Video })[]> { - throw new Error("Playlist operations are not supported with Bunny.net storage"); - } - - async addVideoToPlaylist(playlistVideo: InsertPlaylistVideo): Promise { - throw new Error("Playlist operations are not supported with Bunny.net storage"); - } - - async removeVideoFromPlaylist(playlistId: string, videoId: string): Promise { - throw new Error("Playlist operations are not supported with Bunny.net storage"); - } - - // Favorites operations - Not implemented for BunnyStorage (video-only service) - async getUserFavorites(userId: string, limit?: number, offset?: number): Promise<(UserFavorite & { video: Video })[]> { - throw new Error("Favorites operations are not supported with Bunny.net storage"); - } - - async addToFavorites(favorite: InsertUserFavorite): Promise { - throw new Error("Favorites operations are not supported with Bunny.net storage"); - } - - async removeFromFavorites(userId: string, videoId: string): Promise { - throw new Error("Favorites operations are not supported with Bunny.net storage"); - } - - async isVideoFavorited(userId: string, videoId: string): Promise { - throw new Error("Favorites operations are not supported with Bunny.net storage"); - } -} - -// Create a hybrid storage that combines Bunny.net videos with database user features -import { db } from "./db"; -import { eq, and, desc, asc } from "drizzle-orm"; -import { users, playlists, playlistVideos, userFavorites } from "@shared/schema"; - -class HybridStorage implements IStorage { - private bunnyStorage: BunnyStorage; - - constructor() { - this.bunnyStorage = new BunnyStorage(); - } - - // Video operations - delegate to Bunny.net - async getVideos(limit = 20, offset = 0, search?: string, category?: string): Promise { - return this.bunnyStorage.getVideos(limit, offset, search, category); - } - - async getVideo(id: string): Promise { - return this.bunnyStorage.getVideo(id); - } - - async createVideo(video: InsertVideo): Promise { - return this.bunnyStorage.createVideo(video); - } - - async updateVideoViews(id: string): Promise { - return this.bunnyStorage.updateVideoViews(id); - } - - async getVideoCount(search?: string, category?: string): Promise { - return this.bunnyStorage.getVideoCount(search, category); - } - - // User operations - use database - async getUser(id: string): Promise { - const [user] = await db.select().from(users).where(eq(users.id, id)); - return user; - } - - async getUserByEmail(email: string): Promise { - const [user] = await db.select().from(users).where(eq(users.email, email)); - return user; - } - - async getUserByUsername(username: string): Promise { - const [user] = await db.select().from(users).where(eq(users.username, username)); - return user; - } - - async createUser(userData: InsertUser): Promise { - const [user] = await db.insert(users).values({ - ...userData, - createdAt: new Date(), - updatedAt: new Date() - }).returning(); - return user; - } - - async updateUser(id: string, userData: Partial): Promise { - const [user] = await db.update(users) - .set({ ...userData, updatedAt: new Date() }) - .where(eq(users.id, id)) - .returning(); - return user; - } - - // Playlist operations - use database - async getPlaylists(userId: string, limit = 20, offset = 0): Promise { - const userPlaylists = await db.select() - .from(playlists) - .where(eq(playlists.userId, userId)) - .orderBy(desc(playlists.createdAt)) - .limit(limit) - .offset(offset); - return userPlaylists; - } - - async getPlaylist(id: string, userId?: string): Promise { - let query = db.select().from(playlists).where(eq(playlists.id, id)); - if (userId) { - query = query.where(eq(playlists.userId, userId)); - } - const [playlist] = await query; - return playlist; - } - - async createPlaylist(playlistData: InsertPlaylist): Promise { - const [playlist] = await db.insert(playlists).values({ - ...playlistData, - createdAt: new Date(), - updatedAt: new Date() - }).returning(); - return playlist; - } - - async updatePlaylist(id: string, updates: Partial, userId: string): Promise { - const [playlist] = await db.update(playlists) - .set({ ...updates, updatedAt: new Date() }) - .where(and(eq(playlists.id, id), eq(playlists.userId, userId))) - .returning(); - return playlist; - } - - async deletePlaylist(id: string, userId: string): Promise { - const result = await db.delete(playlists) - .where(and(eq(playlists.id, id), eq(playlists.userId, userId))); - return result.rowCount > 0; - } - - // Playlist video operations - use database - async getPlaylistVideos(playlistId: string): Promise<(PlaylistVideo & { video: Video })[]> { - const playlistVideoRecords = await db.select() - .from(playlistVideos) - .where(eq(playlistVideos.playlistId, playlistId)) - .orderBy(asc(playlistVideos.position)); - - const results = []; - for (const record of playlistVideoRecords) { - const video = await this.getVideo(record.videoId); - if (video) { - results.push({ ...record, video }); - } - } - return results; - } - - async addVideoToPlaylist(data: InsertPlaylistVideo): Promise { - // Check if video already exists in playlist - const existing = await db.select() - .from(playlistVideos) - .where(and( - eq(playlistVideos.playlistId, data.playlistId), - eq(playlistVideos.videoId, data.videoId) - )); - - if (existing.length > 0) { - throw new Error("Video is already in playlist"); - } - - const [playlistVideo] = await db.insert(playlistVideos).values({ - ...data, - addedAt: new Date() - }).returning(); - return playlistVideo; - } - - async removeVideoFromPlaylist(playlistId: string, videoId: string): Promise { - const result = await db.delete(playlistVideos) - .where(and( - eq(playlistVideos.playlistId, playlistId), - eq(playlistVideos.videoId, videoId) - )); - return result.rowCount > 0; - } - - // Favorites operations - use database - async getUserFavorites(userId: string, limit = 20, offset = 0): Promise<(UserFavorite & { video: Video })[]> { - const favoriteRecords = await db.select() - .from(userFavorites) - .where(eq(userFavorites.userId, userId)) - .orderBy(desc(userFavorites.createdAt)) - .limit(limit) - .offset(offset); - - const results = []; - for (const record of favoriteRecords) { - const video = await this.getVideo(record.videoId); - if (video) { - results.push({ ...record, video }); - } - } - return results; - } - - async addToFavorites(data: InsertUserFavorite): Promise { - // Check if video already favorited - const existing = await db.select() - .from(userFavorites) - .where(and( - eq(userFavorites.userId, data.userId), - eq(userFavorites.videoId, data.videoId) - )); - - if (existing.length > 0) { - throw new Error("Video is already in favorites"); - } - - const [favorite] = await db.insert(userFavorites).values({ - ...data, - createdAt: new Date() - }).returning(); - return favorite; - } - - async removeFromFavorites(userId: string, videoId: string): Promise { - const result = await db.delete(userFavorites) - .where(and( - eq(userFavorites.userId, userId), - eq(userFavorites.videoId, videoId) - )); - return result.rowCount > 0; - } - - async isVideoFavorited(userId: string, videoId: string): Promise { - const [favorite] = await db.select() - .from(userFavorites) - .where(and( - eq(userFavorites.userId, userId), - eq(userFavorites.videoId, videoId) - )); - return !!favorite; - } } // Try to use Bunny.net storage, fallback to memory storage if not configured @@ -777,10 +230,10 @@ const hasBunnyConfig = process.env.BUNNY_API_KEY && process.env.BUNNY_LIBRARY_ID if (hasBunnyConfig) { try { - storage = new HybridStorage(); - console.log('✅ Using Hybrid storage (Bunny.net + Database) with library ID:', process.env.BUNNY_LIBRARY_ID); + storage = new BunnyStorage(); + console.log('✅ Using Bunny.net storage with library ID:', process.env.BUNNY_LIBRARY_ID); } catch (error) { - console.error('❌ Failed to initialize Hybrid storage:', error); + console.error('❌ Failed to initialize Bunny.net storage:', error); console.log('📁 Falling back to memory storage'); storage = new MemStorage(); } diff --git a/server/thumbnail-generator.ts b/server/thumbnail-generator.ts deleted file mode 100644 index 87ff504..0000000 --- a/server/thumbnail-generator.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { exec } from 'child_process'; -import { promisify } from 'util'; -import * as fs from 'fs'; -import * as path from 'path'; - -const execAsync = promisify(exec); - -export interface ThumbnailOptions { - videoId: string; - timeStamp?: string; // Format: "00:01:30" or "90" (seconds) - width?: number; - height?: number; - quality?: number; -} - -export class ThumbnailGenerator { - private thumbnailDir: string; - - constructor() { - this.thumbnailDir = path.join(process.cwd(), 'thumbnails'); - this.ensureThumbnailDir(); - } - - private ensureThumbnailDir() { - if (!fs.existsSync(this.thumbnailDir)) { - fs.mkdirSync(this.thumbnailDir, { recursive: true }); - } - } - - async generateThumbnail(options: ThumbnailOptions): Promise { - const { videoId, timeStamp = "5", width = 400, height = 225, quality = 85 } = options; - - // Create unique filename based on parameters - const filename = `${videoId}_${timeStamp.replace(/:/g, '-')}_${width}x${height}.jpg`; - const outputPath = path.join(this.thumbnailDir, filename); - - // Check if thumbnail already exists - if (fs.existsSync(outputPath)) { - return outputPath; - } - - try { - // Use Bunny.net direct video URL for FFmpeg - const videoUrl = `https://vz-7982dfc4-cc8.b-cdn.net/${videoId}/playlist.m3u8`; - - // FFmpeg command to extract frame at specific time - const ffmpegCommand = [ - 'ffmpeg', - '-i', `"${videoUrl}"`, - '-ss', timeStamp, - '-vframes', '1', - '-vf', `scale=${width}:${height}`, - '-q:v', quality.toString(), - `"${outputPath}"`, - '-y' - ].join(' '); - - console.log(`Generating thumbnail: ${ffmpegCommand}`); - - const { stdout, stderr } = await execAsync(ffmpegCommand); - - if (fs.existsSync(outputPath)) { - console.log(`Thumbnail generated successfully: ${outputPath}`); - return outputPath; - } else { - console.error('Thumbnail file was not created'); - return null; - } - - } catch (error) { - console.error('Error generating thumbnail:', error); - return null; - } - } - - async generateMultipleThumbnails(videoId: string, timestamps: string[]): Promise { - const results: string[] = []; - - for (const timestamp of timestamps) { - const thumbnailPath = await this.generateThumbnail({ - videoId, - timeStamp: timestamp - }); - - if (thumbnailPath) { - results.push(thumbnailPath); - } - } - - return results; - } - - async enhanceThumbnail(inputPath: string, outputPath: string): Promise { - try { - // Use ImageMagick to enhance the thumbnail - const magickCommand = [ - 'convert', - `"${inputPath}"`, - '-auto-level', - '-enhance', - '-unsharp', '0x1', - `"${outputPath}"` - ].join(' '); - - await execAsync(magickCommand); - - if (fs.existsSync(outputPath)) { - return outputPath; - } - - return null; - } catch (error) { - console.error('Error enhancing thumbnail:', error); - return null; - } - } - - listThumbnails(videoId: string): string[] { - try { - const files = fs.readdirSync(this.thumbnailDir); - return files - .filter(file => file.startsWith(videoId) && file.endsWith('.jpg')) - .map(file => path.join(this.thumbnailDir, file)); - } catch (error) { - console.error('Error listing thumbnails:', error); - return []; - } - } - - deleteThumbnail(videoId: string, timestamp?: string): boolean { - try { - if (timestamp) { - // Delete specific thumbnail - const filename = `${videoId}_${timestamp.replace(/:/g, '-')}_400x225.jpg`; - const filePath = path.join(this.thumbnailDir, filename); - if (fs.existsSync(filePath)) { - fs.unlinkSync(filePath); - return true; - } - } else { - // Delete all thumbnails for video - const files = fs.readdirSync(this.thumbnailDir); - const videoThumbnails = files.filter(file => file.startsWith(videoId)); - - for (const file of videoThumbnails) { - fs.unlinkSync(path.join(this.thumbnailDir, file)); - } - - return videoThumbnails.length > 0; - } - - return false; - } catch (error) { - console.error('Error deleting thumbnail:', error); - return false; - } - } -} \ No newline at end of file diff --git a/shared/schema.ts b/shared/schema.ts index 63fd51b..25ced8e 100644 --- a/shared/schema.ts +++ b/shared/schema.ts @@ -1,6 +1,5 @@ import { sql } from "drizzle-orm"; -import { pgTable, text, varchar, integer, timestamp, boolean, primaryKey, uuid } from "drizzle-orm/pg-core"; -import { relations } from "drizzle-orm"; +import { pgTable, text, varchar, integer, timestamp } from "drizzle-orm/pg-core"; import { createInsertSchema } from "drizzle-zod"; import { z } from "zod"; @@ -23,121 +22,3 @@ export const insertVideoSchema = createInsertSchema(videos).omit({ export type InsertVideo = z.infer; export type Video = typeof videos.$inferSelect; - -// Users table for authentication and user management -export const users = pgTable("users", { - id: uuid("id").primaryKey().default(sql`gen_random_uuid()`), - username: varchar("username", { length: 50 }).notNull().unique(), - email: varchar("email", { length: 255 }).notNull().unique(), - passwordHash: varchar("password_hash", { length: 255 }).notNull(), - firstName: varchar("first_name", { length: 100 }), - lastName: varchar("last_name", { length: 100 }), - avatar: text("avatar"), - isActive: boolean("is_active").notNull().default(true), - createdAt: timestamp("created_at").notNull().default(sql`CURRENT_TIMESTAMP`), - updatedAt: timestamp("updated_at").notNull().default(sql`CURRENT_TIMESTAMP`), -}); - -export const insertUserSchema = createInsertSchema(users).omit({ - id: true, - createdAt: true, - updatedAt: true, -}); - -export type InsertUser = z.infer; -export type User = typeof users.$inferSelect; - -// Playlists table -export const playlists = pgTable("playlists", { - id: uuid("id").primaryKey().default(sql`gen_random_uuid()`), - userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), - name: varchar("name", { length: 255 }).notNull(), - description: text("description"), - isPublic: boolean("is_public").notNull().default(false), - thumbnailUrl: text("thumbnail_url"), - createdAt: timestamp("created_at").notNull().default(sql`CURRENT_TIMESTAMP`), - updatedAt: timestamp("updated_at").notNull().default(sql`CURRENT_TIMESTAMP`), -}); - -export const insertPlaylistSchema = createInsertSchema(playlists).omit({ - id: true, - createdAt: true, - updatedAt: true, -}); - -export type InsertPlaylist = z.infer; -export type Playlist = typeof playlists.$inferSelect; - -// Playlist videos junction table -export const playlistVideos = pgTable("playlist_videos", { - playlistId: uuid("playlist_id").notNull().references(() => playlists.id, { onDelete: "cascade" }), - videoId: varchar("video_id").notNull().references(() => videos.id, { onDelete: "cascade" }), - position: integer("position").notNull().default(0), - addedAt: timestamp("added_at").notNull().default(sql`CURRENT_TIMESTAMP`), -}, (table) => ({ - pk: primaryKey({ columns: [table.playlistId, table.videoId] }), -})); - -export const insertPlaylistVideoSchema = createInsertSchema(playlistVideos).omit({ - addedAt: true, -}); - -export type InsertPlaylistVideo = z.infer; -export type PlaylistVideo = typeof playlistVideos.$inferSelect; - -// User favorites table -export const userFavorites = pgTable("user_favorites", { - userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), - videoId: varchar("video_id").notNull().references(() => videos.id, { onDelete: "cascade" }), - createdAt: timestamp("created_at").notNull().default(sql`CURRENT_TIMESTAMP`), -}, (table) => ({ - pk: primaryKey({ columns: [table.userId, table.videoId] }), -})); - -export const insertUserFavoriteSchema = createInsertSchema(userFavorites).omit({ - createdAt: true, -}); - -export type InsertUserFavorite = z.infer; -export type UserFavorite = typeof userFavorites.$inferSelect; - -// Relations -export const usersRelations = relations(users, ({ many }) => ({ - playlists: many(playlists), - favorites: many(userFavorites), -})); - -export const playlistsRelations = relations(playlists, ({ one, many }) => ({ - user: one(users, { - fields: [playlists.userId], - references: [users.id], - }), - playlistVideos: many(playlistVideos), -})); - -export const videosRelations = relations(videos, ({ many }) => ({ - playlistVideos: many(playlistVideos), - userFavorites: many(userFavorites), -})); - -export const playlistVideosRelations = relations(playlistVideos, ({ one }) => ({ - playlist: one(playlists, { - fields: [playlistVideos.playlistId], - references: [playlists.id], - }), - video: one(videos, { - fields: [playlistVideos.videoId], - references: [videos.id], - }), -})); - -export const userFavoritesRelations = relations(userFavorites, ({ one }) => ({ - user: one(users, { - fields: [userFavorites.userId], - references: [users.id], - }), - video: one(videos, { - fields: [userFavorites.videoId], - references: [videos.id], - }), -})); diff --git a/test.svg b/test.svg deleted file mode 100644 index 7762322..0000000 --- a/test.svg +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - - - - - - - - - - - - - Alex Reichinger - Ciao mia bella - - - 3:02 - - \ No newline at end of file diff --git a/test_thumbnail.jpg b/test_thumbnail.jpg deleted file mode 100644 index 264fce4..0000000 --- a/test_thumbnail.jpg +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - - - - AlexReichinger-Ciaomiabella - - - 3:02 - - - \ No newline at end of file
- Generirajte thumbnail slike iz različnih trenutkov videa "{video.title}" -
- Vnesite čas v sekundah (0 - {video.duration || 300}s) -
Navodila:
- {playlist.description} -
- You don't have any playlists yet. -
- Videos you've marked as favorites -
- Start adding videos to your favorites by clicking the heart icon on any video -
- {favorite.video.description} -
- Organize your favorite videos into custom playlists -
- Create your first playlist to organize your favorite videos -