package services import ( "context" "fmt" "os" "path/filepath" "strings" "veza-backend-api/internal/models" "github.com/google/uuid" "gorm.io/gorm" "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 } // GetStreamInfo récupère les informations générales d'un stream HLS pour un track // BE-API-020: Implement HLS stream info endpoint func (s *HLSService) GetStreamInfo(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) } info := map[string]interface{}{ "track_id": stream.TrackID, "playlist_url": stream.PlaylistURL, "bitrates": stream.Bitrates, "segments_count": stream.SegmentsCount, "created_at": stream.CreatedAt, "updated_at": stream.UpdatedAt, } return info, nil }