diff --git a/apps/web/src/features/upload/components/upload-modal/UploadModalMetadataForm.tsx b/apps/web/src/features/upload/components/upload-modal/UploadModalMetadataForm.tsx index e052855b7..265355f2c 100644 --- a/apps/web/src/features/upload/components/upload-modal/UploadModalMetadataForm.tsx +++ b/apps/web/src/features/upload/components/upload-modal/UploadModalMetadataForm.tsx @@ -5,9 +5,25 @@ import type { UploadFormData } from './constants'; export interface UploadModalMetadataFormProps { register: (name: keyof UploadFormData) => { value: string; onChange: (e: React.ChangeEvent) => void }; errors: Partial>; + /** + * v1.0.9 W4 Day 17 — gate the FLAC-availability checkbox on premium + * subscribers. When the prop is omitted (default), the checkbox is + * hidden ; consumers wire it up by reading the current user's role + * from useUser() and passing role === 'premium' || 'admin' (or the + * equivalent subscription-tier check once Item G phase 4 lands). + */ + showFlacAvailable?: boolean; + flacAvailable?: boolean; + onFlacAvailableChange?: (next: boolean) => void; } -export function UploadModalMetadataForm({ register, errors }: UploadModalMetadataFormProps) { +export function UploadModalMetadataForm({ + register, + errors, + showFlacAvailable, + flacAvailable, + onFlacAvailableChange, +}: UploadModalMetadataFormProps) { return (

Métadonnées (optionnel)

@@ -48,6 +64,28 @@ export function UploadModalMetadataForm({ register, errors }: UploadModalMetadat />
+ + {showFlacAvailable && ( +
+ onFlacAvailableChange?.(e.target.checked)} + data-testid="upload-flac-available" + className="mt-1" + /> +
+ +

+ Mark this track as FLAC-deliverable for Premium listeners. Lossless distribution rolls out + post-launch ; this flag reserves it on your release. +

+
+
+ )} ); } diff --git a/apps/web/src/features/upload/components/upload-modal/constants.ts b/apps/web/src/features/upload/components/upload-modal/constants.ts index bae9c5b22..1da852292 100644 --- a/apps/web/src/features/upload/components/upload-modal/constants.ts +++ b/apps/web/src/features/upload/components/upload-modal/constants.ts @@ -14,6 +14,12 @@ export type UploadFormData = { artist: string; album: string; genre: string; + // v1.0.9 W4 Day 17 — Premium creators can flag a track for FLAC + // delivery. The flag is captured here for future use ; the actual + // FLAC distribution path is post-launch (transcoder ladder needs + // a lossless step + storage class accounting). Today this is a UI + // affordance only — the checkbox is hidden for non-premium roles. + flacAvailable?: boolean; }; export const MAX_RETRY_ATTEMPTS = 3; diff --git a/veza-backend-api/.env.template b/veza-backend-api/.env.template index 4b9a4a783..e8f034e9a 100644 --- a/veza-backend-api/.env.template +++ b/veza-backend-api/.env.template @@ -161,7 +161,9 @@ CHAT_SERVER_URL=http://veza.fr:8081 # which works for all tracks but without adaptive bitrate. Set to true to # activate HLS manifests/segments (requires stream server + transcoding). # See FUNCTIONAL_AUDIT §4 item 5 for the fallback behaviour. -HLS_STREAMING=false +# v1.0.9 W4 Day 17 : default flipped to true. Set to false to disable +# the HLS transcoder pipeline in lightweight dev / unit-test envs. +HLS_STREAMING=true # HLS segment storage directory (used only when HLS_STREAMING=true) HLS_STORAGE_DIR=/tmp/veza-hls diff --git a/veza-backend-api/internal/config/config.go b/veza-backend-api/internal/config/config.go index bd63d1723..f4c1bcc9d 100644 --- a/veza-backend-api/internal/config/config.go +++ b/veza-backend-api/internal/config/config.go @@ -412,8 +412,10 @@ func NewConfig() (*Config, error) { OAuthEncryptionKey: getEnv("OAUTH_ENCRYPTION_KEY", ""), OAuthAllowedRedirectDomains: getOAuthAllowedRedirectDomains(env, getEnvStringSlice("OAUTH_ALLOWED_REDIRECT_DOMAINS", nil), corsOrigins, getFrontendURL()), - // HLS Streaming (v0.503) - HLSEnabled: getEnvBool("HLS_STREAMING", false), + // HLS Streaming (v0.503 ; default flipped to true in v1.0.9 W4 Day 17 — + // HLS_STREAMING=false now an explicit opt-out for dev / test envs that + // don't want the transcoder pipeline running). + HLSEnabled: getEnvBool("HLS_STREAMING", true), HLSStorageDir: getEnv("HLS_STORAGE_DIR", "/tmp/veza-hls"), // S3 Storage Configuration (BE-SVC-005) diff --git a/veza-backend-api/internal/core/marketplace/models.go b/veza-backend-api/internal/core/marketplace/models.go index c7cd5f8b2..8faae643c 100644 --- a/veza-backend-api/internal/core/marketplace/models.go +++ b/veza-backend-api/internal/core/marketplace/models.go @@ -45,6 +45,13 @@ type Product struct { MusicalKey string `gorm:"column:musical_key;size:10" json:"musical_key,omitempty"` Category string `gorm:"column:category;size:50" json:"category,omitempty"` // sample, beat, preset, pack + // v1.0.9 W4 Day 17 — creator-opt-in 30s pre-listen for marketplace + // products. When true, anonymous /api/v1/marketplace/products/:id/preview + // returns a streaming URL capped to ~30s. Trust model is documented + // in the handler — client-side cutoff is sufficient for the + // "tease-to-buy" use case ; not anti-rip. + PreviewEnabled bool `gorm:"column:preview_enabled;not null;default:false" json:"preview_enabled"` + // Relations Previews []ProductPreview `gorm:"foreignKey:ProductID" json:"previews,omitempty"` Images []ProductImage `gorm:"foreignKey:ProductID" json:"images,omitempty"` diff --git a/veza-backend-api/internal/core/track/track_hls_handler.go b/veza-backend-api/internal/core/track/track_hls_handler.go index f117ba820..34291fee1 100644 --- a/veza-backend-api/internal/core/track/track_hls_handler.go +++ b/veza-backend-api/internal/core/track/track_hls_handler.go @@ -1,6 +1,7 @@ package track import ( + "context" "errors" "fmt" "net/http" @@ -299,6 +300,12 @@ func (h *TrackHandler) StreamTrack(c *gin.Context) { h.respondWithError(c, http.StatusForbidden, "invalid share token") return } + } else if c.Query("preview") == "30" && h.isMarketplacePreviewAllowed(c.Request.Context(), trackID) { + // v1.0.9 W4 Day 17 — marketplace 30s pre-listen. The creator + // opted-in via products.preview_enabled=true ; we allow + // anonymous bytes through, the frontend caps at 30s via + // audio.currentTime. Trust model documented in the migration. + c.Header("X-Preview-Cap-Seconds", "30") } else if !track.IsPublic && track.UserID != userID { h.respondWithError(c, http.StatusForbidden, "forbidden") return @@ -353,6 +360,34 @@ func (h *TrackHandler) StreamTrack(c *gin.Context) { http.ServeContent(c.Writer, c.Request, track.Title, stat.ModTime(), file) } +// isMarketplacePreviewAllowed reports whether the track is referenced +// by a non-deleted product whose seller opted into the 30s pre-listen. +// v1.0.9 W4 Day 17 — used by StreamTrack to allow anonymous bytes +// when ?preview=30 is set. Raw SQL deliberately : keeping the track +// package free of the marketplace package import (the reverse arrow +// already exists, so adding this one would create a cycle). +func (h *TrackHandler) isMarketplacePreviewAllowed(ctx context.Context, trackID uuid.UUID) bool { + if h.trackService == nil || h.trackService.db == nil { + return false + } + var exists int + err := h.trackService.db.WithContext(ctx). + Raw(`SELECT 1 FROM products + WHERE track_id = ? + AND preview_enabled = TRUE + AND deleted_at IS NULL + LIMIT 1`, trackID).Scan(&exists).Error + if err != nil { + // Failure-closed : a query error returns false ; the request + // then falls through to the regular public/owner check. + h.trackService.logger.Warn("preview gate query failed", + zap.String("track_id", trackID.String()), + zap.Error(err)) + return false + } + return exists == 1 +} + // getContentType retourne le Content-Type approprié pour un format audio func getContentType(format string) string { switch strings.ToUpper(format) { diff --git a/veza-backend-api/internal/handlers/marketplace.go b/veza-backend-api/internal/handlers/marketplace.go index aa52ae59d..74d44e566 100644 --- a/veza-backend-api/internal/handlers/marketplace.go +++ b/veza-backend-api/internal/handlers/marketplace.go @@ -394,6 +394,12 @@ func (h *MarketplaceHandler) GetProduct(c *gin.Context) { // StreamProductPreview streams the first audio preview for a product (v0.401 M1) // GET /marketplace/products/:id/preview +// +// v1.0.9 W4 Day 17 — when the product has no file-based preview but +// has product_type='track', track_id linked, AND preview_enabled=true, +// fall through to serving a 30-second slice of the underlying track. +// The 30s cap is enforced client-side (HTML5 audio currentTime) ; this +// is documented as the "tease-to-buy" trust model in the migration. func (h *MarketplaceHandler) StreamProductPreview(c *gin.Context) { productID, err := uuid.Parse(c.Param("id")) if err != nil { @@ -401,18 +407,44 @@ func (h *MarketplaceHandler) StreamProductPreview(c *gin.Context) { return } product, err := h.service.GetProduct(c.Request.Context(), productID) - if err != nil || len(product.Previews) == 0 { - response.NotFound(c, "Preview not found") + if err != nil { + response.NotFound(c, "Product not found") return } - preview := product.Previews[0] - fullPath := filepath.Join(h.uploadDir, preview.FilePath) - if _, err := os.Stat(fullPath); os.IsNotExist(err) { - response.NotFound(c, "Preview file not found") + + // Path 1 : file-based preview already uploaded (legacy path). + if len(product.Previews) > 0 { + preview := product.Previews[0] + fullPath := filepath.Join(h.uploadDir, preview.FilePath) + if _, err := os.Stat(fullPath); os.IsNotExist(err) { + response.NotFound(c, "Preview file not found") + return + } + c.Header("Content-Disposition", "inline") + c.Header("X-Preview-Source", "file") + c.Header("X-Preview-Cap-Seconds", "0") // file is already trimmed at upload + c.File(fullPath) return } - c.Header("Content-Disposition", "inline") - c.File(fullPath) + + // Path 2 (Day 17) : creator-opt-in 30s slice from the underlying + // track. Only honored when the product is a track product AND + // preview_enabled=true AND a track is linked. + if product.ProductType == "track" && product.PreviewEnabled && product.TrackID != nil { + // Redirect to the track stream endpoint with a query flag the + // frontend reads to enforce the 30s cap (audio.currentTime). + // Backend doesn't byte-cap : that requires bitrate-aware + // truncation which depends on the audio format. Industry + // standard (BandCamp / SoundCloud) uses the same client-cap + // model for this acceptance gate. + c.Header("X-Preview-Source", "track-30s") + c.Header("X-Preview-Cap-Seconds", "30") + c.Redirect(http.StatusFound, + "/api/v1/tracks/"+product.TrackID.String()+"/stream?preview=30") + return + } + + response.NotFound(c, "Preview not available for this product") } // UpdateProductImagesRequest body for PUT /products/:id/images diff --git a/veza-backend-api/migrations/989_products_preview_enabled.sql b/veza-backend-api/migrations/989_products_preview_enabled.sql new file mode 100644 index 000000000..6422879a2 --- /dev/null +++ b/veza-backend-api/migrations/989_products_preview_enabled.sql @@ -0,0 +1,26 @@ +-- 989_products_preview_enabled.sql +-- v1.0.9 W4 Day 17 — creator opt-in 30s pre-listen for marketplace products. +-- +-- When TRUE, the public /api/v1/marketplace/products/:id/preview endpoint +-- returns a streaming URL for the underlying track capped to ~30 seconds. +-- Default is FALSE so adoption is opt-in — creators flip the flag from +-- the product edit page. +-- +-- Trust model : the 30s cap is enforced client-side (HTML5 audio +-- element listens for currentTime >= 30 and pauses). Any user with +-- devtools could bypass — that's accepted because the use case is +-- "give me a taste before I buy", not anti-rip. Industry standard ; +-- BandCamp / SoundCloud do the same. + +ALTER TABLE public.products + ADD COLUMN IF NOT EXISTS preview_enabled BOOLEAN NOT NULL DEFAULT FALSE; + +COMMENT ON COLUMN public.products.preview_enabled IS + 'TRUE when the seller opted into a 30s pre-listen for buyers. v1.0.9 W4 Day 17.'; + +-- Lookup index — admin queries / preview endpoint may filter by this. +-- Partial index is enough since the majority of products won't opt in +-- at first. +CREATE INDEX IF NOT EXISTS idx_products_preview_enabled + ON public.products(preview_enabled) + WHERE preview_enabled = TRUE; diff --git a/veza-backend-api/migrations/rollback/989_products_preview_enabled_down.sql b/veza-backend-api/migrations/rollback/989_products_preview_enabled_down.sql new file mode 100644 index 000000000..f5826c4fe --- /dev/null +++ b/veza-backend-api/migrations/rollback/989_products_preview_enabled_down.sql @@ -0,0 +1,5 @@ +-- 989 rollback : drop the preview_enabled column on products. +-- Idempotent ; the index disappears with the column. + +DROP INDEX IF EXISTS idx_products_preview_enabled; +ALTER TABLE public.products DROP COLUMN IF EXISTS preview_enabled;