diff --git a/backend/scripts/migrate-to-sqlite.ts b/backend/scripts/migrate-to-sqlite.ts index a651712..6f7dad7 100644 --- a/backend/scripts/migrate-to-sqlite.ts +++ b/backend/scripts/migrate-to-sqlite.ts @@ -37,9 +37,35 @@ async function migrate() { seriesTitle: video.seriesTitle, rating: video.rating, description: video.description, - viewCount: video.viewCount, + viewCount: video.viewCount || 0, + progress: video.progress || 0, duration: video.duration, - }).onConflictDoNothing(); + }).onConflictDoUpdate({ + target: videos.id, + set: { + title: video.title, + author: video.author, + date: video.date, + source: video.source, + sourceUrl: video.sourceUrl, + videoFilename: video.videoFilename, + thumbnailFilename: video.thumbnailFilename, + videoPath: video.videoPath, + thumbnailPath: video.thumbnailPath, + thumbnailUrl: video.thumbnailUrl, + addedAt: video.addedAt, + createdAt: video.createdAt, + updatedAt: video.updatedAt, + partNumber: video.partNumber, + totalParts: video.totalParts, + seriesTitle: video.seriesTitle, + rating: video.rating, + description: video.description, + viewCount: video.viewCount || 0, + progress: video.progress || 0, + duration: video.duration, + } + }); } catch (error) { console.error(`Error migrating video ${video.id}:`, error); } diff --git a/backend/scripts/update-durations.ts b/backend/scripts/update-durations.ts new file mode 100644 index 0000000..718239c --- /dev/null +++ b/backend/scripts/update-durations.ts @@ -0,0 +1,79 @@ +import { exec } from 'child_process'; +import { eq } from 'drizzle-orm'; +import fs from 'fs-extra'; +import path from 'path'; +import { VIDEOS_DIR } from '../src/config/paths'; +import { db } from '../src/db'; +import { videos } from '../src/db/schema'; + +async function updateDurations() { + console.log('Starting duration update...'); + + // Get all videos with missing duration + // Note: We can't easily filter by isNull(videos.duration) if the column was just added and defaults to null, + // but let's try to get all videos and check in JS if needed, or just update all. + // Updating all is safer to ensure correctness. + + const allVideos = await db.select().from(videos).all(); + console.log(`Found ${allVideos.length} videos.`); + + let updatedCount = 0; + + for (const video of allVideos) { + if (video.duration) { + // Skip if already has duration (optional: remove this check to force update) + continue; + } + + let videoPath = video.videoPath; + if (!videoPath) continue; + + // Resolve absolute path + // videoPath in DB is web path like "/videos/subdir/file.mp4" + // We need filesystem path. + // Assuming /videos maps to VIDEOS_DIR + + let fsPath = ''; + if (videoPath.startsWith('/videos/')) { + const relativePath = videoPath.replace('/videos/', ''); + fsPath = path.join(VIDEOS_DIR, relativePath); + } else { + // Fallback or other path structure + continue; + } + + if (!fs.existsSync(fsPath)) { + console.warn(`File not found: ${fsPath}`); + continue; + } + + try { + const duration = await new Promise((resolve, reject) => { + exec(`ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "${fsPath}"`, (error, stdout, stderr) => { + if (error) { + reject(error); + } else { + resolve(stdout.trim()); + } + }); + }); + + if (duration) { + const durationSec = parseFloat(duration); + if (!isNaN(durationSec)) { + await db.update(videos) + .set({ duration: Math.round(durationSec).toString() }) + .where(eq(videos.id, video.id)); + console.log(`Updated duration for ${video.title}: ${Math.round(durationSec)}s`); + updatedCount++; + } + } + } catch (error) { + console.error(`Error getting duration for ${video.title}:`, error); + } + } + + console.log(`Finished. Updated ${updatedCount} videos.`); +} + +updateDurations().catch(console.error); diff --git a/backend/src/controllers/scanController.ts b/backend/src/controllers/scanController.ts index 7ca744d..a4ceefd 100644 --- a/backend/src/controllers/scanController.ts +++ b/backend/src/controllers/scanController.ts @@ -117,6 +117,28 @@ export const scanFiles = async (req: Request, res: Response): Promise => { }); }); + // Get duration + let duration = undefined; + try { + const durationOutput = await new Promise((resolve, reject) => { + exec(`ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "${filePath}"`, (error, stdout, stderr) => { + if (error) { + reject(error); + } else { + resolve(stdout.trim()); + } + }); + }); + if (durationOutput) { + const durationSec = parseFloat(durationOutput); + if (!isNaN(durationSec)) { + duration = Math.round(durationSec).toString(); + } + } + } catch (err) { + console.error("Error getting duration:", err); + } + const newVideo = { id: videoId, title: path.parse(filename).name, @@ -131,6 +153,7 @@ export const scanFiles = async (req: Request, res: Response): Promise => { createdAt: createdDate.toISOString(), addedAt: new Date().toISOString(), date: createdDate.toISOString().split('T')[0].replace(/-/g, ''), + duration: duration, }; storageService.saveVideo(newVideo); diff --git a/backend/src/controllers/videoController.ts b/backend/src/controllers/videoController.ts index 070e455..d0235cc 100644 --- a/backend/src/controllers/videoController.ts +++ b/backend/src/controllers/videoController.ts @@ -8,12 +8,12 @@ import downloadManager from "../services/downloadManager"; import * as downloadService from "../services/downloadService"; import * as storageService from "../services/storageService"; import { - extractBilibiliVideoId, - extractUrlFromText, - isBilibiliUrl, - isValidUrl, - resolveShortUrl, - trimBilibiliUrl + extractBilibiliVideoId, + extractUrlFromText, + isBilibiliUrl, + isValidUrl, + resolveShortUrl, + trimBilibiliUrl } from "../utils/helpers"; // Configure Multer for file uploads @@ -615,8 +615,55 @@ export const refreshThumbnail = async (req: Request, res: Response): Promise { + try { + const { id } = req.params; + const video = storageService.getVideoById(id); + + if (!video) { + return res.status(404).json({ error: "Video not found" }); + } + + const currentViews = video.viewCount || 0; + const updatedVideo = storageService.updateVideo(id, { viewCount: currentViews + 1 }); + + res.status(200).json({ + success: true, + viewCount: updatedVideo?.viewCount + }); + } catch (error) { + console.error("Error incrementing view count:", error); + res.status(500).json({ error: "Failed to increment view count" }); + } +}; + +// Update progress +export const updateProgress = (req: Request, res: Response): any => { + try { + const { id } = req.params; + const { progress } = req.body; + + if (typeof progress !== 'number') { + return res.status(400).json({ error: "Progress must be a number" }); + } + + const updatedVideo = storageService.updateVideo(id, { progress }); + + if (!updatedVideo) { + return res.status(404).json({ error: "Video not found" }); + } + + res.status(200).json({ + success: true, + progress: updatedVideo.progress + }); + } catch (error) { + console.error("Error updating progress:", error); + res.status(500).json({ error: "Failed to update progress" }); + } +}; diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index d02df6c..b4773f2 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -25,6 +25,7 @@ export const videos = sqliteTable('videos', { viewCount: integer('view_count'), duration: text('duration'), tags: text('tags'), // JSON stringified array of strings + progress: integer('progress'), // Playback progress in seconds }); export const collections = sqliteTable('collections', { diff --git a/backend/src/routes/api.ts b/backend/src/routes/api.ts index 4da6b54..5049d2a 100644 --- a/backend/src/routes/api.ts +++ b/backend/src/routes/api.ts @@ -16,6 +16,8 @@ router.delete("/videos/:id", videoController.deleteVideo); router.get("/videos/:id/comments", videoController.getVideoComments); router.post("/videos/:id/rate", videoController.rateVideo); router.post("/videos/:id/refresh-thumbnail", videoController.refreshThumbnail); +router.post("/videos/:id/view", videoController.incrementViewCount); +router.put("/videos/:id/progress", videoController.updateProgress); router.post("/scan-files", scanController.scanFiles); diff --git a/backend/src/services/storageService.ts b/backend/src/services/storageService.ts index 6114474..8f65b99 100644 --- a/backend/src/services/storageService.ts +++ b/backend/src/services/storageService.ts @@ -19,6 +19,8 @@ export interface Video { thumbnailFilename?: string; createdAt: string; tags?: string[]; + viewCount?: number; + progress?: number; [key: string]: any; } @@ -89,6 +91,32 @@ export function initializeStorage(): void { } catch (error) { console.error("Error checking/migrating tags column:", error); } + + // Check and migrate viewCount and progress columns if needed + try { + const tableInfo = sqlite.prepare("PRAGMA table_info(videos)").all(); + const columns = (tableInfo as any[]).map((col: any) => col.name); + + if (!columns.includes('view_count')) { + console.log("Migrating database: Adding view_count column to videos table..."); + sqlite.prepare("ALTER TABLE videos ADD COLUMN view_count INTEGER DEFAULT 0").run(); + console.log("Migration successful: view_count added."); + } + + if (!columns.includes('progress')) { + console.log("Migrating database: Adding progress column to videos table..."); + sqlite.prepare("ALTER TABLE videos ADD COLUMN progress INTEGER DEFAULT 0").run(); + console.log("Migration successful: progress added."); + } + + if (!columns.includes('duration')) { + console.log("Migrating database: Adding duration column to videos table..."); + sqlite.prepare("ALTER TABLE videos ADD COLUMN duration TEXT").run(); + console.log("Migration successful: duration added."); + } + } catch (error) { + console.error("Error checking/migrating viewCount/progress/duration columns:", error); + } } diff --git a/data/mytube.db b/data/mytube.db new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/components/VideoCard.tsx b/frontend/src/components/VideoCard.tsx index efea5b1..d52ab9b 100644 --- a/frontend/src/components/VideoCard.tsx +++ b/frontend/src/components/VideoCard.tsx @@ -60,6 +60,24 @@ const VideoCard: React.FC = ({ return `${year}-${month}-${day}`; }; + // Format duration (seconds or MM:SS) + const formatDuration = (duration: string | number | undefined) => { + if (!duration) return null; + + // If it's already a string with colon, assume it's formatted + if (typeof duration === 'string' && duration.includes(':')) { + return duration; + } + + // Otherwise treat as seconds + const seconds = typeof duration === 'string' ? parseInt(duration, 10) : duration; + if (isNaN(seconds)) return null; + + const minutes = Math.floor(seconds / 60); + const remainingSeconds = Math.floor(seconds % 60); + return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`; + }; + // Use local thumbnail if available, otherwise fall back to the original URL const thumbnailSrc = video.thumbnailPath ? `${BACKEND_URL}${video.thumbnailPath}` @@ -183,7 +201,23 @@ const VideoCard: React.FC = ({ label={`${t('part')} ${video.partNumber}/${video.totalParts}`} size="small" color="primary" - sx={{ position: 'absolute', bottom: 8, right: 8 }} + sx={{ position: 'absolute', bottom: 36, right: 8 }} + /> + )} + + {video.duration && ( + )} @@ -226,6 +260,9 @@ const VideoCard: React.FC = ({ {formatDate(video.date)} + + {video.viewCount || 0} {t('views')} + diff --git a/frontend/src/components/VideoPlayer/VideoControls.tsx b/frontend/src/components/VideoPlayer/VideoControls.tsx index d10dff4..d01fe91 100644 --- a/frontend/src/components/VideoPlayer/VideoControls.tsx +++ b/frontend/src/components/VideoPlayer/VideoControls.tsx @@ -24,12 +24,18 @@ interface VideoControlsProps { src: string; autoPlay?: boolean; autoLoop?: boolean; + onTimeUpdate?: (currentTime: number) => void; + onLoadedMetadata?: (duration: number) => void; + startTime?: number; } const VideoControls: React.FC = ({ src, autoPlay = false, - autoLoop = false + autoLoop = false, + onTimeUpdate, + onLoadedMetadata, + startTime = 0 }) => { const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down('sm')); @@ -59,9 +65,28 @@ const VideoControls: React.FC = ({ setIsFullscreen(!!document.fullscreenElement); }; + const handleWebkitBeginFullscreen = () => { + setIsFullscreen(true); + }; + + const handleWebkitEndFullscreen = () => { + setIsFullscreen(false); + }; + + const videoElement = videoRef.current; + document.addEventListener('fullscreenchange', handleFullscreenChange); + if (videoElement) { + videoElement.addEventListener('webkitbeginfullscreen', handleWebkitBeginFullscreen); + videoElement.addEventListener('webkitendfullscreen', handleWebkitEndFullscreen); + } + return () => { document.removeEventListener('fullscreenchange', handleFullscreenChange); + if (videoElement) { + videoElement.removeEventListener('webkitbeginfullscreen', handleWebkitBeginFullscreen); + videoElement.removeEventListener('webkitendfullscreen', handleWebkitEndFullscreen); + } }; }, []); @@ -85,14 +110,25 @@ const VideoControls: React.FC = ({ const handleToggleFullscreen = () => { const videoContainer = videoRef.current?.parentElement; - if (!videoContainer) return; + const videoElement = videoRef.current; + + if (!videoContainer || !videoElement) return; if (!document.fullscreenElement) { - videoContainer.requestFullscreen().catch(err => { - console.error(`Error attempting to enable fullscreen: ${err.message}`); - }); + // Try standard fullscreen first (for Desktop, Android, iPad) + if (videoContainer.requestFullscreen) { + videoContainer.requestFullscreen().catch(err => { + console.error(`Error attempting to enable fullscreen: ${err.message}`); + }); + } + // Fallback for iPhone Safari + else if ((videoElement as any).webkitEnterFullscreen) { + (videoElement as any).webkitEnterFullscreen(); + } } else { - document.exitFullscreen(); + if (document.exitFullscreen) { + document.exitFullscreen(); + } } }; @@ -107,7 +143,7 @@ const VideoControls: React.FC = ({