veza/veza-backend-api/internal/services/hls_service.go

295 lines
11 KiB
Go

package services
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/google/uuid"
"gorm.io/gorm"
"veza-backend-api/internal/models"
"go.uber.org/zap"
)
// HLSService gère la récupération et le service des fichiers HLS
type HLSService struct {
db *gorm.DB
outputDir string
logger *zap.Logger
transcodeService *HLSTranscodeService
queueService *HLSQueueService
}
// NewHLSService crée un nouveau service HLS
func NewHLSService(db *gorm.DB, outputDir string, logger *zap.Logger) *HLSService {
if logger == nil {
logger = zap.NewNop()
}
return &HLSService{
db: db,
outputDir: outputDir,
logger: logger,
}
}
// NewHLSServiceWithTranscode crée un nouveau service HLS avec service de transcodage
func NewHLSServiceWithTranscode(db *gorm.DB, outputDir string, transcodeService *HLSTranscodeService, logger *zap.Logger) *HLSService {
if logger == nil {
logger = zap.NewNop()
}
return &HLSService{
db: db,
outputDir: outputDir,
logger: logger,
transcodeService: transcodeService,
}
}
// SetTranscodeService définit le service de transcodage
func (s *HLSService) SetTranscodeService(transcodeService *HLSTranscodeService) {
s.transcodeService = transcodeService
}
// SetQueueService définit le service de queue HLS
func (s *HLSService) SetQueueService(queueService *HLSQueueService) {
s.queueService = queueService
}
// GetMasterPlaylist récupère le contenu du master playlist pour un track
func (s *HLSService) GetMasterPlaylist(ctx context.Context, trackID uuid.UUID) (string, error) {
var hlsStream models.HLSStream
if err := s.db.WithContext(ctx).Where("track_id = ? AND status = ?", trackID, models.HLSStatusReady).First(&hlsStream).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return "", fmt.Errorf("HLS stream not found for track %s", trackID)
}
return "", fmt.Errorf("failed to query HLS stream: %w", err)
}
// Lire le fichier master.m3u8
// Le PlaylistURL est relatif au outputDir (ex: track_123/master.m3u8)
masterPlaylistPath := hlsStream.PlaylistURL
if !filepath.IsAbs(masterPlaylistPath) {
// Si c'est un chemin relatif, il devrait déjà être relatif à outputDir
// Vérifier si c'est déjà un chemin complet ou relatif
if !strings.HasPrefix(masterPlaylistPath, s.outputDir) {
masterPlaylistPath = filepath.Join(s.outputDir, masterPlaylistPath)
}
}
content, err := os.ReadFile(masterPlaylistPath)
if err != nil {
if os.IsNotExist(err) {
return "", fmt.Errorf("master playlist file not found: %s", masterPlaylistPath)
}
return "", fmt.Errorf("failed to read master playlist: %w", err)
}
return string(content), nil
}
// GetQualityPlaylist récupère le contenu d'une quality playlist pour un track et bitrate
func (s *HLSService) GetQualityPlaylist(ctx context.Context, trackID uuid.UUID, bitrate string) (string, error) {
var hlsStream models.HLSStream
if err := s.db.WithContext(ctx).Where("track_id = ? AND status = ?", trackID, models.HLSStatusReady).First(&hlsStream).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return "", fmt.Errorf("HLS stream not found for track %s", trackID)
}
return "", fmt.Errorf("failed to query HLS stream: %w", err)
}
// Construire le chemin vers la quality playlist
trackDir := filepath.Join(s.outputDir, fmt.Sprintf("track_%s", trackID))
qualityPlaylistPath := filepath.Join(trackDir, bitrate, "playlist.m3u8")
content, err := os.ReadFile(qualityPlaylistPath)
if err != nil {
if os.IsNotExist(err) {
return "", fmt.Errorf("quality playlist file not found: %s", qualityPlaylistPath)
}
return "", fmt.Errorf("failed to read quality playlist: %w", err)
}
return string(content), nil
}
// GetSegmentPath récupère le chemin complet d'un segment pour un track, bitrate et nom de segment
func (s *HLSService) GetSegmentPath(ctx context.Context, trackID uuid.UUID, bitrate string, segment string) (string, error) {
var hlsStream models.HLSStream
if err := s.db.WithContext(ctx).Where("track_id = ? AND status = ?", trackID, models.HLSStatusReady).First(&hlsStream).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return "", fmt.Errorf("HLS stream not found for track %s", trackID)
}
return "", fmt.Errorf("failed to query HLS stream: %w", err)
}
// Construire le chemin vers le segment
trackDir := filepath.Join(s.outputDir, fmt.Sprintf("track_%s", trackID))
segmentPath := filepath.Join(trackDir, bitrate, segment)
// Vérifier que le fichier existe
if _, err := os.Stat(segmentPath); os.IsNotExist(err) {
return "", fmt.Errorf("segment file not found: %s", segmentPath)
}
// Vérifier que le chemin est sécurisé (pas de directory traversal)
absSegmentPath, err := filepath.Abs(segmentPath)
if err != nil {
return "", fmt.Errorf("failed to get absolute path: %w", err)
}
absTrackDir, err := filepath.Abs(trackDir)
if err != nil {
return "", fmt.Errorf("failed to get absolute track dir: %w", err)
}
// Vérifier que le segment est bien dans le répertoire du track
if !strings.HasPrefix(absSegmentPath, absTrackDir) {
return "", fmt.Errorf("invalid segment path: %s", segmentPath)
}
return absSegmentPath, nil
}
// TriggerTranscode déclenche le transcodage d'un track en HLS
func (s *HLSService) TriggerTranscode(ctx context.Context, track *models.Track) error {
if s.transcodeService == nil {
return fmt.Errorf("transcode service not configured")
}
if track == nil {
return fmt.Errorf("track cannot be nil")
}
// Vérifier si un stream existe déjà pour ce track
var existingStream models.HLSStream
err := s.db.WithContext(ctx).Where("track_id = ?", track.ID).First(&existingStream).Error
if err == nil {
// Un stream existe déjà, vérifier son statut
if existingStream.Status == models.HLSStatusReady {
return fmt.Errorf("HLS stream already exists and is ready for track %s", track.ID)
}
// Si le stream est en cours de traitement ou a échoué, on peut le retranscoder
if existingStream.Status == models.HLSStatusProcessing {
return fmt.Errorf("HLS stream is already being processed for track %s", track.ID)
}
// Supprimer l'ancien stream si nécessaire
if err := s.db.WithContext(ctx).Delete(&existingStream).Error; err != nil {
s.logger.Warn("Failed to delete existing stream", zap.Error(err), zap.String("track_id", track.ID.String()))
}
}
// Mettre à jour le statut du track si nécessaire
if err := s.db.WithContext(ctx).Model(track).Update("status", models.TrackStatusProcessing).Error; err != nil {
s.logger.Warn("Failed to update track status", zap.Error(err), zap.String("track_id", track.ID.String()))
}
// Créer un stream en statut "processing"
hlsStream := &models.HLSStream{
TrackID: track.ID,
Status: models.HLSStatusProcessing,
}
if err := s.db.WithContext(ctx).Create(hlsStream).Error; err != nil {
return fmt.Errorf("failed to create HLS stream record: %w", err)
}
// Transcoder le track
transcodedStream, err := s.transcodeService.TranscodeTrack(ctx, track)
if err != nil {
// Mettre à jour le statut en "failed"
s.db.WithContext(ctx).Model(hlsStream).Update("status", models.HLSStatusFailed)
return fmt.Errorf("failed to transcode track: %w", err)
}
// Mettre à jour le stream avec les données du transcodage
hlsStream.PlaylistURL = transcodedStream.PlaylistURL
hlsStream.SegmentsCount = transcodedStream.SegmentsCount
hlsStream.Bitrates = transcodedStream.Bitrates
hlsStream.Status = models.HLSStatusReady
if err := s.db.WithContext(ctx).Save(hlsStream).Error; err != nil {
return fmt.Errorf("failed to update HLS stream: %w", err)
}
// Mettre à jour le statut du track
if err := s.db.WithContext(ctx).Model(track).Update("status", models.TrackStatusCompleted).Error; err != nil {
s.logger.Warn("Failed to update track status to completed", zap.Error(err), zap.String("track_id", track.ID.String()))
}
s.logger.Info("HLS transcoding completed", zap.String("track_id", track.ID.String()), zap.String("stream_id", hlsStream.ID.String()))
return nil
}
// TriggerTranscodeQueue déclenche le transcodage HLS via la queue (T0343)
// Vérifie les permissions et ajoute un job dans la queue
// MIGRATION UUID: userID migré vers uuid.UUID
func (s *HLSService) TriggerTranscodeQueue(ctx context.Context, trackID uuid.UUID, userID uuid.UUID) (uuid.UUID, error) {
if s.queueService == nil {
return uuid.Nil, fmt.Errorf("queue service not configured")
}
// Vérifier que le track existe et que l'utilisateur est propriétaire
var track models.Track
if err := s.db.WithContext(ctx).First(&track, "id = ?", trackID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return uuid.Nil, fmt.Errorf("track not found")
}
return uuid.Nil, fmt.Errorf("failed to query track: %w", err)
}
// Vérifier les permissions (UUID comparison)
if track.UserID != userID {
return uuid.Nil, fmt.Errorf("forbidden: user does not own this track")
}
// Ajouter le job dans la queue avec priorité par défaut (5)
priority := 5
jobID, err := s.queueService.EnqueueWithID(ctx, trackID, priority)
if err != nil {
return uuid.Nil, fmt.Errorf("failed to enqueue transcode job: %w", err)
}
s.logger.Info("HLS transcode job enqueued", zap.String("job_id", jobID.String()), zap.String("track_id", trackID.String()), zap.String("user_id", userID.String()))
return jobID, nil
}
// GetStreamStatus récupère le statut d'un stream HLS pour un track
func (s *HLSService) GetStreamStatus(ctx context.Context, trackID uuid.UUID) (map[string]interface{}, error) {
var stream models.HLSStream
if err := s.db.WithContext(ctx).Where("track_id = ?", trackID).First(&stream).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, fmt.Errorf("HLS stream not found for track %s", trackID)
}
return nil, fmt.Errorf("failed to query HLS stream: %w", err)
}
status := map[string]interface{}{
"status": stream.Status,
"bitrates": stream.Bitrates,
"segments_count": stream.SegmentsCount,
"playlist_url": stream.PlaylistURL,
"track_id": stream.TrackID,
"created_at": stream.CreatedAt,
"updated_at": stream.UpdatedAt,
}
// Ajouter des informations supplémentaires si le stream est en cours de traitement
if stream.Status == models.HLSStatusProcessing {
// Vérifier s'il y a un job de transcodage en cours
var queueJob models.HLSTranscodeQueue
if err := s.db.WithContext(ctx).
Where("track_id = ? AND status = ?", trackID, models.QueueStatusProcessing).
First(&queueJob).Error; err == nil {
status["queue_job_id"] = queueJob.ID
status["retry_count"] = queueJob.RetryCount
if queueJob.StartedAt != nil {
status["started_at"] = queueJob.StartedAt
}
}
}
return status, nil
}