Some checks failed
Veza CI / Rust (Stream Server) (push) Successful in 5m12s
Security Scan / Secret Scanning (gitleaks) (push) Failing after 54s
Veza CI / Backend (Go) (push) Failing after 8m38s
Veza CI / Frontend (Web) (push) Failing after 16m44s
Veza CI / Notify on failure (push) Successful in 15s
E2E Playwright / e2e (full) (push) Successful in 20m28s
CDN edge in front of S3/MinIO via origin-pull. Backend signs URLs with Bunny.net token-auth (SHA-256 over security_key + path + expires) so edges verify before serving cached objects ; origin is never hit on a valid token. Cloudflare CDN / R2 / CloudFront stubs kept. - internal/services/cdn_service.go : new providers CDNProviderBunny + CDNProviderCloudflareR2. SecurityKey added to CDNConfig. generateBunnySignedURL implements the documented Bunny scheme (url-safe base64, no padding, expires query). HLSSegmentCacheHeaders + HLSPlaylistCacheHeaders helpers exported for handlers. - internal/services/cdn_service_test.go : pin Bunny URL shape + base64-url charset ; assert empty SecurityKey fails fast (no silent fallback to unsigned URLs). - internal/core/track/service.go : new CDNURLSigner interface + SetCDNService(cdn). GetStorageURL prefers CDN signed URL when cdnService.IsEnabled, falls back to direct S3 presign on signing error so a CDN partial outage doesn't block playback. - internal/api/routes_tracks.go + routes_core.go : wire SetCDNService on the two TrackService construction sites that serve stream/download. - internal/config/config.go : 4 new env vars (CDN_ENABLED, CDN_PROVIDER, CDN_BASE_URL, CDN_SECURITY_KEY). config.CDNService always non-nil after init ; IsEnabled gates the actual usage. - internal/handlers/hls_handler.go : segments now return Cache-Control: public, max-age=86400, immutable (content-addressed filenames make this safe). Playlists at max-age=60. - veza-backend-api/.env.template : 4 placeholder env vars. - docs/ENV_VARIABLES.md §12 : provider matrix + Bunny vs Cloudflare vs R2 trade-offs. Bug fix collateral : v1.0.9 Day 11 introduced veza_cache_hits_total which collided in name with monitoring.CacheHitsTotal (different label set ⇒ promauto MustRegister panic at process init). Day 13 deletes the monitoring duplicate and restores the metrics-package counter as the single source of truth (label: subsystem). All 8 affected packages green : services, core/track, handlers, middleware, websocket/chat, metrics, monitoring, config. Acceptance (Day 13) : code path is wired ; verifying via real Bunny edge requires a Pull Zone provisioned by the user (EX-? in roadmap). On the user side : create Pull Zone w/ origin = MinIO, copy token auth key into CDN_SECURITY_KEY, set CDN_ENABLED=true. W3 progress : Redis Sentinel ✓ · MinIO distribué ✓ · CDN ✓ · DMCA ⏳ Day 14 · embed ⏳ Day 15. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
197 lines
6.5 KiB
Go
197 lines
6.5 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
// "strconv" // Removed this import
|
|
|
|
apperrors "veza-backend-api/internal/errors"
|
|
"veza-backend-api/internal/services"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// HLSServiceInterface defines methods needed for HLS handler
|
|
type HLSServiceInterface interface {
|
|
GetMasterPlaylist(ctx context.Context, trackID uuid.UUID) (string, error)
|
|
GetQualityPlaylist(ctx context.Context, trackID uuid.UUID, bitrate string) (string, error)
|
|
GetSegmentPath(ctx context.Context, trackID uuid.UUID, bitrate string, segment string) (string, error)
|
|
GetStreamInfo(ctx context.Context, trackID uuid.UUID) (map[string]interface{}, error)
|
|
GetStreamStatus(ctx context.Context, trackID uuid.UUID) (map[string]interface{}, error)
|
|
TriggerTranscodeQueue(ctx context.Context, trackID uuid.UUID, userID uuid.UUID) (uuid.UUID, error)
|
|
}
|
|
|
|
// HLSHandler gère les requêtes pour servir les fichiers HLS
|
|
type HLSHandler struct {
|
|
hlsService HLSServiceInterface
|
|
}
|
|
|
|
// NewHLSHandler crée un nouveau handler HLS
|
|
func NewHLSHandler(hlsService *services.HLSService) *HLSHandler {
|
|
return NewHLSHandlerWithInterface(hlsService)
|
|
}
|
|
|
|
// NewHLSHandlerWithInterface crée un nouveau handler HLS avec une interface (pour tests)
|
|
func NewHLSHandlerWithInterface(hlsService HLSServiceInterface) *HLSHandler {
|
|
return &HLSHandler{hlsService: hlsService}
|
|
}
|
|
|
|
// ServeMasterPlaylist sert le master playlist pour un track
|
|
func (h *HLSHandler) ServeMasterPlaylist(c *gin.Context) {
|
|
trackID, err := uuid.Parse(c.Param("id")) // Changed to uuid.Parse
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid track id"})
|
|
return
|
|
}
|
|
|
|
playlist, err := h.hlsService.GetMasterPlaylist(c.Request.Context(), trackID)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "playlist not found"})
|
|
return
|
|
}
|
|
|
|
c.Header("Content-Type", "application/vnd.apple.mpegurl")
|
|
// v1.0.9 W3 Day 13 — playlists CAN change (live streams, transcoder
|
|
// retries, bitrate ladder edits) so 60s TTL is the right balance :
|
|
// edges still cache aggressively but a stale manifest only blocks
|
|
// the viewer briefly.
|
|
c.Header("Cache-Control", "public, max-age=60")
|
|
c.Header("Vary", "Accept-Encoding")
|
|
c.String(http.StatusOK, playlist)
|
|
}
|
|
|
|
// ServeQualityPlaylist sert une quality playlist pour un track et bitrate
|
|
func (h *HLSHandler) ServeQualityPlaylist(c *gin.Context) {
|
|
trackID, err := uuid.Parse(c.Param("id")) // Changed to uuid.Parse
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid track id"})
|
|
return
|
|
}
|
|
|
|
bitrate := c.Param("bitrate")
|
|
playlist, err := h.hlsService.GetQualityPlaylist(c.Request.Context(), trackID, bitrate)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "playlist not found"})
|
|
return
|
|
}
|
|
|
|
c.Header("Content-Type", "application/vnd.apple.mpegurl")
|
|
// v1.0.9 W3 Day 13 — playlists CAN change (live streams, transcoder
|
|
// retries, bitrate ladder edits) so 60s TTL is the right balance :
|
|
// edges still cache aggressively but a stale manifest only blocks
|
|
// the viewer briefly.
|
|
c.Header("Cache-Control", "public, max-age=60")
|
|
c.Header("Vary", "Accept-Encoding")
|
|
c.String(http.StatusOK, playlist)
|
|
}
|
|
|
|
// ServeSegment sert un segment pour un track, bitrate et nom de segment
|
|
func (h *HLSHandler) ServeSegment(c *gin.Context) {
|
|
trackID, err := uuid.Parse(c.Param("id")) // Changed to uuid.Parse
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid track id"})
|
|
return
|
|
}
|
|
|
|
bitrate := c.Param("bitrate")
|
|
segment := c.Param("segment")
|
|
|
|
segmentPath, err := h.hlsService.GetSegmentPath(c.Request.Context(), trackID, bitrate, segment)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "segment not found"})
|
|
return
|
|
}
|
|
|
|
c.Header("Content-Type", "video/mp2t")
|
|
// v1.0.9 W3 Day 13 — segments are content-addressed (filename
|
|
// includes a hash). max-age=86400 + immutable lets every layer
|
|
// (browser, CDN, origin) skip re-validation entirely. If a segment
|
|
// regenerates after a re-encode, its filename changes — so this
|
|
// directive is safe.
|
|
c.Header("Cache-Control", "public, max-age=86400, immutable")
|
|
c.Header("Vary", "Accept-Encoding")
|
|
c.File(segmentPath)
|
|
}
|
|
|
|
// GetStreamInfo retourne les informations générales d'un stream HLS pour un track
|
|
// GET /api/v1/tracks/:id/hls/info
|
|
// BE-API-020: Implement HLS stream info endpoint
|
|
func (h *HLSHandler) GetStreamInfo(c *gin.Context) {
|
|
trackID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
RespondWithAppError(c, apperrors.NewValidationError("invalid track id"))
|
|
return
|
|
}
|
|
|
|
info, err := h.hlsService.GetStreamInfo(c.Request.Context(), trackID)
|
|
if err != nil {
|
|
if err.Error()[:20] == "HLS stream not found" {
|
|
RespondWithAppError(c, apperrors.NewNotFoundError("HLS stream"))
|
|
return
|
|
}
|
|
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to get stream info", err))
|
|
return
|
|
}
|
|
|
|
RespondSuccess(c, http.StatusOK, info)
|
|
}
|
|
|
|
// GetStreamStatus retourne le statut d'un stream HLS pour un track
|
|
// GET /api/v1/tracks/:id/hls/status
|
|
// BE-API-020: Implement HLS stream info endpoint
|
|
func (h *HLSHandler) GetStreamStatus(c *gin.Context) {
|
|
trackID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
RespondWithAppError(c, apperrors.NewValidationError("invalid track id"))
|
|
return
|
|
}
|
|
|
|
status, err := h.hlsService.GetStreamStatus(c.Request.Context(), trackID)
|
|
if err != nil {
|
|
if err.Error()[:20] == "HLS stream not found" {
|
|
RespondWithAppError(c, apperrors.NewNotFoundError("HLS stream"))
|
|
return
|
|
}
|
|
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to get stream status", err))
|
|
return
|
|
}
|
|
|
|
RespondSuccess(c, http.StatusOK, status)
|
|
}
|
|
|
|
// TriggerTranscode déclenche le transcodage HLS d'un track via la queue (T0343)
|
|
func (h *HLSHandler) TriggerTranscode(c *gin.Context) {
|
|
userID, ok := GetUserIDUUID(c)
|
|
if !ok {
|
|
return // Erreur déjà envoyée par GetUserIDUUID
|
|
}
|
|
if userID == uuid.Nil {
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
|
return
|
|
}
|
|
|
|
trackID, err := uuid.Parse(c.Param("id")) // Changed to uuid.Parse
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid track id"})
|
|
return
|
|
}
|
|
|
|
jobID, err := h.hlsService.TriggerTranscodeQueue(c.Request.Context(), trackID, userID)
|
|
if err != nil {
|
|
if err.Error() == "track not found" {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "track not found"})
|
|
return
|
|
}
|
|
if err.Error() == "forbidden: user does not own this track" {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "forbidden"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusAccepted, gin.H{"job_id": jobID})
|
|
}
|