From 0020c13bfd57103d8f6956b6f2a5fdfd2e0b6228 Mon Sep 17 00:00:00 2001 From: EvanChal Date: Sun, 25 Jan 2026 18:08:38 +0100 Subject: [PATCH] fix(video-player): fix the video player to permit the nagivation through the video (it was because de fast api server refused range request) --- backend/api/routers/vlogs.py | 35 ++++-- backend/app.py | 86 ++++++++++++++- backend/models/vlog.py | 23 +++- backend/schemas/vlog.py | 1 + backend/utils/settings_service.py | 98 ++++++++-------- frontend/src/components/VideoPlayer.vue | 141 +++++++++++++++++++----- frontend/src/layouts/DefaultLayout.vue | 18 ++- frontend/src/views/AlbumDetail.vue | 16 +-- frontend/src/views/Albums.vue | 8 +- frontend/src/views/EventDetail.vue | 31 +----- frontend/src/views/Events.vue | 59 +--------- frontend/src/views/Home.vue | 26 ++--- frontend/src/views/Posts.vue | 12 +- frontend/src/views/VlogDetail.vue | 36 +++--- frontend/src/views/Vlogs.vue | 8 +- package-lock.json | 6 + package.json | 1 + 17 files changed, 393 insertions(+), 212 deletions(-) create mode 100644 package-lock.json create mode 100644 package.json diff --git a/backend/api/routers/vlogs.py b/backend/api/routers/vlogs.py index 0482f2b..7fea8fd 100644 --- a/backend/api/routers/vlogs.py +++ b/backend/api/routers/vlogs.py @@ -6,7 +6,7 @@ import uuid from pathlib import Path from config.database import get_db from config.settings import settings -from models.vlog import Vlog, VlogLike, VlogComment +from models.vlog import Vlog, VlogLike, VlogComment, VlogView from models.user import User from schemas.vlog import VlogCreate, VlogUpdate, VlogResponse, VlogCommentCreate from utils.security import get_current_active_user @@ -56,8 +56,21 @@ async def get_vlog( detail="Vlog not found" ) - # Increment view count - vlog.views_count += 1 + # Manage views and replays + view = db.query(VlogView).filter( + VlogView.vlog_id == vlog_id, + VlogView.user_id == current_user.id + ).first() + + if view: + # User has already viewed this vlog -> Count as replay + vlog.replays_count = (vlog.replays_count or 0) + 1 + else: + # First time viewing -> Count as unique view + new_view = VlogView(vlog_id=vlog_id, user_id=current_user.id) + db.add(new_view) + vlog.views_count += 1 + db.commit() return format_vlog_response(vlog, db, current_user.id) @@ -149,10 +162,15 @@ async def toggle_vlog_like( message = "Like removed" else: # Like - like = VlogLike(vlog_id=vlog_id, user_id=current_user.id) - db.add(like) - vlog.likes_count += 1 - message = "Vlog liked" + try: + like = VlogLike(vlog_id=vlog_id, user_id=current_user.id) + db.add(like) + vlog.likes_count += 1 + message = "Vlog liked" + except Exception: + # Handle potential race condition or constraint violation + db.rollback() + return {"message": "Already liked", "likes_count": vlog.likes_count} db.commit() return {"message": message, "likes_count": vlog.likes_count} @@ -244,7 +262,7 @@ async def upload_vlog_video( # Check file size video_content = await video.read() - max_size = SettingsService.get_max_upload_size(db, video.content_type or "video/mp4") + max_size = SettingsService.get_max_upload_size(db, video.content_type or "video/mp4", is_vlog=True) if len(video_content) > max_size: max_size_mb = max_size // (1024 * 1024) raise HTTPException( @@ -363,6 +381,7 @@ def format_vlog_response(vlog: Vlog, db: Session, current_user_id: int) -> dict: "thumbnail_url": vlog.thumbnail_url, "duration": vlog.duration, "views_count": vlog.views_count, + "replays_count": vlog.replays_count, "likes_count": vlog.likes_count, "created_at": vlog.created_at, "updated_at": vlog.updated_at, diff --git a/backend/app.py b/backend/app.py index a56c125..6cf6a59 100644 --- a/backend/app.py +++ b/backend/app.py @@ -1,9 +1,10 @@ -from fastapi import FastAPI +from fastapi import FastAPI, Request, Response, HTTPException from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from contextlib import asynccontextmanager import os from pathlib import Path +import mimetypes from config.settings import settings from config.database import engine, Base @@ -198,10 +199,89 @@ app.add_middleware( allow_credentials=True, allow_methods=["*"], allow_headers=["*"], + expose_headers=["Content-Range", "Accept-Ranges"], ) -# Mount static files for uploads -app.mount("/uploads", StaticFiles(directory=settings.UPLOAD_PATH), name="uploads") +# Endpoint personnalisé pour servir les vidéos avec support Range +@app.get("/uploads/{file_path:path}") +async def serve_media_with_range(request: Request, file_path: str): + """ + Serve media files with proper Range request support for video scrubbing. + """ + file_full_path = Path(settings.UPLOAD_PATH) / file_path + + # Vérifier que le fichier existe + if not file_full_path.exists() or not file_full_path.is_file(): + raise HTTPException(status_code=404, detail="File not found") + + # Vérifier que le fichier est dans le répertoire uploads (sécurité) + try: + file_full_path.resolve().relative_to(Path(settings.UPLOAD_PATH).resolve()) + except ValueError: + raise HTTPException(status_code=403, detail="Access denied") + + # Obtenir la taille du fichier + file_size = file_full_path.stat().st_size + + # Déterminer le content type + content_type, _ = mimetypes.guess_type(str(file_full_path)) + if not content_type: + content_type = "application/octet-stream" + + # Gérer les requêtes Range + range_header = request.headers.get("Range") + + if range_header: + # Parser le header Range (format: bytes=start-end) + range_match = range_header.replace("bytes=", "").split("-") + start = int(range_match[0]) if range_match[0] else 0 + end = int(range_match[1]) if range_match[1] and range_match[1] else file_size - 1 + + # Valider la plage + if start >= file_size or end >= file_size or start > end: + return Response( + status_code=416, + headers={ + "Content-Range": f"bytes */{file_size}", + "Accept-Ranges": "bytes" + } + ) + + # Lire la plage demandée + chunk_size = end - start + 1 + with open(file_full_path, "rb") as f: + f.seek(start) + chunk = f.read(chunk_size) + + # Retourner la réponse 206 Partial Content + return Response( + content=chunk, + status_code=206, + headers={ + "Content-Range": f"bytes {start}-{end}/{file_size}", + "Accept-Ranges": "bytes", + "Content-Length": str(chunk_size), + "Content-Type": content_type, + }, + media_type=content_type + ) + else: + # Pas de Range header, retourner le fichier complet + with open(file_full_path, "rb") as f: + content = f.read() + + return Response( + content=content, + headers={ + "Accept-Ranges": "bytes", + "Content-Length": str(file_size), + "Content-Type": content_type, + }, + media_type=content_type + ) + +# Note: StaticFiles mount retiré car notre endpoint personnalisé gère tous les fichiers +# avec support Range pour permettre le scrubbing vidéo # Include routers app.include_router(auth.router, prefix="/api/auth", tags=["Authentication"]) diff --git a/backend/models/vlog.py b/backend/models/vlog.py index f17b4de..6ebd68f 100644 --- a/backend/models/vlog.py +++ b/backend/models/vlog.py @@ -1,4 +1,4 @@ -from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text +from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text, UniqueConstraint from sqlalchemy.orm import relationship from datetime import datetime from config.database import Base @@ -14,6 +14,7 @@ class Vlog(Base): thumbnail_url = Column(String) duration = Column(Integer) # in seconds views_count = Column(Integer, default=0) + replays_count = Column(Integer, default=0) likes_count = Column(Integer, default=0) created_at = Column(DateTime, default=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) @@ -35,6 +36,10 @@ class VlogLike(Base): vlog = relationship("Vlog", back_populates="likes") user = relationship("User", back_populates="vlog_likes") + __table_args__ = ( + UniqueConstraint('vlog_id', 'user_id', name='_vlog_user_like_uc'), + ) + class VlogComment(Base): __tablename__ = "vlog_comments" @@ -48,3 +53,19 @@ class VlogComment(Base): # Relationships vlog = relationship("Vlog", back_populates="comments") user = relationship("User", back_populates="vlog_comments") + +class VlogView(Base): + __tablename__ = "vlog_views" + + id = Column(Integer, primary_key=True, index=True) + vlog_id = Column(Integer, ForeignKey("vlogs.id"), nullable=False) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + created_at = Column(DateTime, default=datetime.utcnow) + + # Relationships + vlog = relationship("Vlog") + user = relationship("User") + + __table_args__ = ( + UniqueConstraint('vlog_id', 'user_id', name='_vlog_user_view_uc'), + ) diff --git a/backend/schemas/vlog.py b/backend/schemas/vlog.py index 3d4bd7c..822df2e 100644 --- a/backend/schemas/vlog.py +++ b/backend/schemas/vlog.py @@ -49,6 +49,7 @@ class VlogResponse(VlogBase): thumbnail_url: Optional[str] duration: Optional[int] views_count: int + replays_count: int = 0 likes_count: int created_at: datetime updated_at: datetime diff --git a/backend/utils/settings_service.py b/backend/utils/settings_service.py index e18125b..b7f8acc 100644 --- a/backend/utils/settings_service.py +++ b/backend/utils/settings_service.py @@ -1,49 +1,53 @@ +import os +from typing import Any, Dict, List from sqlalchemy.orm import Session from models.settings import SystemSettings -from typing import Optional, Dict, Any -import json class SettingsService: """Service for managing system settings.""" @staticmethod def get_setting(db: Session, key: str, default: Any = None) -> Any: - """Get a setting value by key.""" + """Get a setting value by key, return default if not found.""" setting = db.query(SystemSettings).filter(SystemSettings.key == key).first() if not setting: return default - - # Essayer de convertir en type approprié - value = setting.value - - # Booléens - if value.lower() in ['true', 'false']: - return value.lower() == 'true' - - # Nombres entiers - try: - return int(value) - except ValueError: - pass - - # Nombres flottants - try: - return float(value) - except ValueError: - pass - - # JSON - if value.startswith('{') or value.startswith('['): + + # Convert value based on expected type (basic handling) + if isinstance(default, int): try: - return json.loads(value) - except json.JSONDecodeError: - pass + return int(setting.value) + except ValueError: + return default + elif isinstance(default, bool): + return setting.value.lower() == "true" + elif isinstance(default, list): + return setting.value.split(",") + + return setting.value + + @staticmethod + def set_setting(db: Session, key: str, value: str, description: str = None, category: str = "general") -> SystemSettings: + """Set a setting value.""" + setting = db.query(SystemSettings).filter(SystemSettings.key == key).first() + if setting: + setting.value = str(value) + if description: + setting.description = description + if category: + setting.category = category + else: + setting = SystemSettings( + key=key, + value=str(value), + description=description, + category=category + ) + db.add(setting) - # Liste séparée par des virgules - if ',' in value and not value.startswith('{') and not value.startswith('['): - return [item.strip() for item in value.split(',')] - - return value + db.commit() + db.refresh(setting) + return setting @staticmethod def get_upload_limits(db: Session) -> Dict[str, Any]: @@ -60,7 +64,7 @@ class SettingsService: } @staticmethod - def get_max_upload_size(db: Session, content_type: str) -> int: + def get_max_upload_size(db: Session, content_type: str, is_vlog: bool = False) -> int: """Get max upload size for a specific content type.""" if content_type.startswith('image/'): max_size_mb = SettingsService.get_setting(db, "max_image_size_mb", 10) @@ -68,9 +72,14 @@ class SettingsService: print(f"DEBUG - Image upload limit: {max_size_mb}MB = {max_size_bytes} bytes") return max_size_bytes elif content_type.startswith('video/'): - max_size_mb = SettingsService.get_setting(db, "max_video_size_mb", 100) + if is_vlog: + max_size_mb = SettingsService.get_setting(db, "max_vlog_size_mb", 500) + print(f"DEBUG - Vlog upload limit: {max_size_mb}MB") + else: + max_size_mb = SettingsService.get_setting(db, "max_video_size_mb", 100) + print(f"DEBUG - Video upload limit: {max_size_mb}MB") + max_size_bytes = max_size_mb * 1024 * 1024 - print(f"DEBUG - Video upload limit: {max_size_mb}MB = {max_size_bytes} bytes") return max_size_bytes else: default_size = 10 * 1024 * 1024 # 10MB par défaut @@ -79,14 +88,17 @@ class SettingsService: @staticmethod def is_file_type_allowed(db: Session, content_type: str) -> bool: - """Check if a file type is allowed.""" + """Check if file type is allowed.""" if content_type.startswith('image/'): - allowed_types = SettingsService.get_setting(db, "allowed_image_types", + allowed = SettingsService.get_setting(db, "allowed_image_types", ["image/jpeg", "image/png", "image/gif", "image/webp"]) + if isinstance(allowed, str): + allowed = allowed.split(",") + return content_type in allowed elif content_type.startswith('video/'): - allowed_types = SettingsService.get_setting(db, "allowed_video_types", + allowed = SettingsService.get_setting(db, "allowed_video_types", ["video/mp4", "video/mpeg", "video/quicktime", "video/webm"]) - else: - return False - - return content_type in allowed_types + if isinstance(allowed, str): + allowed = allowed.split(",") + return content_type in allowed + return False diff --git a/frontend/src/components/VideoPlayer.vue b/frontend/src/components/VideoPlayer.vue index 3cb45c0..05b3cd1 100644 --- a/frontend/src/components/VideoPlayer.vue +++ b/frontend/src/components/VideoPlayer.vue @@ -2,29 +2,37 @@
- +
+ +
- - - {{ viewsCount }} vue{{ viewsCount > 1 ? 's' : '' }} - +
+ + + {{ viewsCount }} + + + + {{ replaysCount }} + +
{{ formatDuration(duration) }} @@ -57,7 +65,7 @@ import { ref, onMounted, onBeforeUnmount, watch, computed } from 'vue' import videojs from 'video.js' import 'video.js/dist/video-js.css' -import { Eye, Clock, Heart, MessageSquare } from 'lucide-vue-next' +import { Eye, Clock, Heart, MessageSquare, RotateCcw } from 'lucide-vue-next' import { getMediaUrl } from '@/utils/axios' const props = defineProps({ @@ -85,6 +93,10 @@ const props = defineProps({ type: Number, default: 0 }, + replaysCount: { + type: Number, + default: 0 + }, likesCount: { type: Number, default: 0 @@ -103,6 +115,7 @@ const emit = defineEmits(['like', 'toggle-comments']) const videoPlayer = ref(null) const player = ref(null) +const currentVideoSrc = ref(null) // Track la source actuelle pour éviter les rechargements inutiles // Computed properties pour les URLs const videoUrl = computed(() => getMediaUrl(props.src)) @@ -124,16 +137,62 @@ function toggleComments() { emit('toggle-comments') } +// Fonction pour gérer les raccourcis clavier manuellement +function handleKeydown(e) { + if (!player.value) return; + + // Ignorer si l'utilisateur tape dans un input + if (['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName)) return; + + switch(e.key) { + case 'ArrowLeft': + e.preventDefault(); + player.value.currentTime(Math.max(0, player.value.currentTime() - 10)); + break; + case 'ArrowRight': + e.preventDefault(); + player.value.currentTime(Math.min(player.value.duration(), player.value.currentTime() + 10)); + break; + case ' ': + case 'Space': // Espace pour pause/play + e.preventDefault(); + if (player.value.paused()) { + player.value.play(); + } else { + player.value.pause(); + } + break; + } +} + onMounted(() => { if (videoPlayer.value) { - player.value = videojs(videoPlayer.value, { + // Options de base pour Video.js + const options = { controls: true, fluid: true, responsive: true, playbackRates: [0.5, 0.75, 1, 1.25, 1.5, 2], + // Désactiver les hotkeys natifs qui causent l'erreur passive listener + userActions: { + hotkeys: false + }, + html5: { + vhs: { + overrideNative: true + }, + nativeAudioTracks: false, + nativeVideoTracks: false + }, controlBar: { + skipButtons: { + forward: 10, + backward: 10 + }, children: [ 'playToggle', + 'skipBackward', + 'skipForward', 'volumePanel', 'currentTimeDisplay', 'timeDivider', @@ -143,12 +202,23 @@ onMounted(() => { 'fullscreenToggle' ] } - }) + }; + + player.value = videojs(videoPlayer.value, options); + + // Définir la source initiale après l'initialisation + if (videoUrl.value) { + player.value.src({ src: videoUrl.value, type: 'video/mp4' }) + currentVideoSrc.value = videoUrl.value + } // Error handling player.value.on('error', (error) => { console.error('Video.js error:', error) }) + + // Ajouter l'écouteur d'événements clavier global + document.addEventListener('keydown', handleKeydown) } }) @@ -156,20 +226,39 @@ onBeforeUnmount(() => { if (player.value) { player.value.dispose() } + // Retirer l'écouteur + document.removeEventListener('keydown', handleKeydown) }) -// Watch for src changes to reload video -watch(() => props.src, () => { - if (player.value && videoUrl.value) { - player.value.src({ src: videoUrl.value, type: 'video/mp4' }) +// Watch for src changes to reload video - amélioré pour éviter les rechargements inutiles +watch(() => videoUrl.value, (newUrl, oldUrl) => { + // Ne recharger que si l'URL a vraiment changé et que le player est prêt + if (player.value && newUrl && newUrl !== currentVideoSrc.value) { + const wasPlaying = !player.value.paused() + const currentTime = player.value.currentTime() + + player.value.src({ src: newUrl, type: 'video/mp4' }) player.value.load() + currentVideoSrc.value = newUrl + + // Restaurer la position si possible (optionnel) + player.value.ready(() => { + if (currentTime > 0 && currentTime < player.value.duration()) { + player.value.currentTime(currentTime) + } + if (wasPlaying) { + player.value.play().catch(() => {}) + } + }) } -}) +}, { immediate: false })