fix(video-player): fix the video player to permit the nagivation through the video (it was because de fast api server refused range request)
Some checks failed
Deploy to Development / build-and-deploy (push) Failing after 2m15s

This commit is contained in:
EvanChal
2026-01-25 18:08:38 +01:00
parent 3fbf372dae
commit 0020c13bfd
17 changed files with 393 additions and 212 deletions

View File

@@ -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,

View File

@@ -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"])

View File

@@ -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'),
)

View File

@@ -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

View File

@@ -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