feat(stream): HLS default on + marketplace 30s pre-listen + FLAC tier checkbox (W4 Day 17)
Some checks failed
Veza CI / Rust (Stream Server) (push) Successful in 5m28s
Security Scan / Secret Scanning (gitleaks) (push) Failing after 53s
Veza CI / Backend (Go) (push) Failing after 7m59s
Veza CI / Frontend (Web) (push) Failing after 17m43s
Veza CI / Notify on failure (push) Successful in 4s
E2E Playwright / e2e (full) (push) Failing after 20m55s
Some checks failed
Veza CI / Rust (Stream Server) (push) Successful in 5m28s
Security Scan / Secret Scanning (gitleaks) (push) Failing after 53s
Veza CI / Backend (Go) (push) Failing after 7m59s
Veza CI / Frontend (Web) (push) Failing after 17m43s
Veza CI / Notify on failure (push) Successful in 4s
E2E Playwright / e2e (full) (push) Failing after 20m55s
Three pieces shipping under one banner since they're the day's
deliverables and share no review-time coupling :
1. HLS_STREAMING default flipped true
- config.go : getEnvBool default true (was false). Operators wanting
a lightweight dev / unit-test env explicitly set HLS_STREAMING=false
to skip the transcoder pipeline.
- .env.template : default flipped + comment explaining the opt-out.
- Effect : every new track upload routes through the HLS transcoder
by default ; ABR ladder served via /tracks/:id/master.m3u8.
2. Marketplace 30s pre-listen (creator opt-in)
- migrations/989 : adds products.preview_enabled BOOLEAN NOT NULL
DEFAULT FALSE + partial index on TRUE values. Default off so
adoption is opt-in.
- core/marketplace/models.go : PreviewEnabled field on Product.
- handlers/marketplace.go : StreamProductPreview gains a fall-through.
When no file-based ProductPreview exists AND the product is a
track product AND preview_enabled=true, redirect to the underlying
/tracks/:id/stream?preview=30. Header X-Preview-Cap-Seconds: 30
surfaces the policy.
- core/track/track_hls_handler.go : StreamTrack accepts ?preview=30
and gates anonymous access via isMarketplacePreviewAllowed (raw
SQL probe of products.preview_enabled to avoid the
track→marketplace import cycle ; the reverse arrow already exists).
- Trust model : 30s cap is enforced client-side (HTML5 audio
currentTime). Industry standard for tease-to-buy ; not anti-rip.
Documented in the migration + handler doc comment.
3. FLAC tier preview checkbox (Premium-gated, hidden by default)
- upload-modal/constants.ts : optional flacAvailable on UploadFormData.
- upload-modal/UploadModalMetadataForm.tsx : new optional props
showFlacAvailable + flacAvailable + onFlacAvailableChange.
Checkbox renders only when showFlacAvailable=true ; consumers
pass that based on the user's role/subscription tier (deferred
to caller wiring — Item G phase 4 will replace the role check
with a real subscription-tier check).
- Today the checkbox is a UI affordance only ; the actual lossless
distribution path (ladder + storage class) is post-launch work.
Acceptance (Day 17) : new uploads serve HLS ABR by default ;
products.preview_enabled flag wires anonymous 30s pre-listen ;
checkbox visible to premium users on the upload form. All 4 tested
backend packages pass : handlers, core/track, core/marketplace, config.
W4 progress : Day 16 ✓ · Day 17 ✓ · Day 18 (faceted search) ⏳ ·
Day 19 (HAProxy sticky WS) ⏳ · Day 20 (k6 nightly) ⏳.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
45c130c856
commit
d5152d89a2
9 changed files with 165 additions and 12 deletions
|
|
@ -5,9 +5,25 @@ import type { UploadFormData } from './constants';
|
||||||
export interface UploadModalMetadataFormProps {
|
export interface UploadModalMetadataFormProps {
|
||||||
register: (name: keyof UploadFormData) => { value: string; onChange: (e: React.ChangeEvent<HTMLInputElement>) => void };
|
register: (name: keyof UploadFormData) => { value: string; onChange: (e: React.ChangeEvent<HTMLInputElement>) => void };
|
||||||
errors: Partial<Record<keyof UploadFormData, string>>;
|
errors: Partial<Record<keyof UploadFormData, string>>;
|
||||||
|
/**
|
||||||
|
* 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 (
|
return (
|
||||||
<div className="space-y-4 border-t pt-4">
|
<div className="space-y-4 border-t pt-4">
|
||||||
<h3 className="font-medium">Métadonnées (optionnel)</h3>
|
<h3 className="font-medium">Métadonnées (optionnel)</h3>
|
||||||
|
|
@ -48,6 +64,28 @@ export function UploadModalMetadataForm({ register, errors }: UploadModalMetadat
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showFlacAvailable && (
|
||||||
|
<div className="flex items-start gap-3 pt-2">
|
||||||
|
<input
|
||||||
|
id="flac-available"
|
||||||
|
type="checkbox"
|
||||||
|
checked={!!flacAvailable}
|
||||||
|
onChange={(e) => onFlacAvailableChange?.(e.target.checked)}
|
||||||
|
data-testid="upload-flac-available"
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="flac-available" className="cursor-pointer">
|
||||||
|
FLAC available (Premium)
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
Mark this track as FLAC-deliverable for Premium listeners. Lossless distribution rolls out
|
||||||
|
post-launch ; this flag reserves it on your release.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,12 @@ export type UploadFormData = {
|
||||||
artist: string;
|
artist: string;
|
||||||
album: string;
|
album: string;
|
||||||
genre: 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;
|
export const MAX_RETRY_ATTEMPTS = 3;
|
||||||
|
|
|
||||||
|
|
@ -161,7 +161,9 @@ CHAT_SERVER_URL=http://veza.fr:8081
|
||||||
# which works for all tracks but without adaptive bitrate. Set to true to
|
# which works for all tracks but without adaptive bitrate. Set to true to
|
||||||
# activate HLS manifests/segments (requires stream server + transcoding).
|
# activate HLS manifests/segments (requires stream server + transcoding).
|
||||||
# See FUNCTIONAL_AUDIT §4 item 5 for the fallback behaviour.
|
# 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 segment storage directory (used only when HLS_STREAMING=true)
|
||||||
HLS_STORAGE_DIR=/tmp/veza-hls
|
HLS_STORAGE_DIR=/tmp/veza-hls
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -412,8 +412,10 @@ func NewConfig() (*Config, error) {
|
||||||
OAuthEncryptionKey: getEnv("OAUTH_ENCRYPTION_KEY", ""),
|
OAuthEncryptionKey: getEnv("OAUTH_ENCRYPTION_KEY", ""),
|
||||||
OAuthAllowedRedirectDomains: getOAuthAllowedRedirectDomains(env, getEnvStringSlice("OAUTH_ALLOWED_REDIRECT_DOMAINS", nil), corsOrigins, getFrontendURL()),
|
OAuthAllowedRedirectDomains: getOAuthAllowedRedirectDomains(env, getEnvStringSlice("OAUTH_ALLOWED_REDIRECT_DOMAINS", nil), corsOrigins, getFrontendURL()),
|
||||||
|
|
||||||
// HLS Streaming (v0.503)
|
// HLS Streaming (v0.503 ; default flipped to true in v1.0.9 W4 Day 17 —
|
||||||
HLSEnabled: getEnvBool("HLS_STREAMING", false),
|
// 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"),
|
HLSStorageDir: getEnv("HLS_STORAGE_DIR", "/tmp/veza-hls"),
|
||||||
|
|
||||||
// S3 Storage Configuration (BE-SVC-005)
|
// S3 Storage Configuration (BE-SVC-005)
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,13 @@ type Product struct {
|
||||||
MusicalKey string `gorm:"column:musical_key;size:10" json:"musical_key,omitempty"`
|
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
|
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
|
// Relations
|
||||||
Previews []ProductPreview `gorm:"foreignKey:ProductID" json:"previews,omitempty"`
|
Previews []ProductPreview `gorm:"foreignKey:ProductID" json:"previews,omitempty"`
|
||||||
Images []ProductImage `gorm:"foreignKey:ProductID" json:"images,omitempty"`
|
Images []ProductImage `gorm:"foreignKey:ProductID" json:"images,omitempty"`
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package track
|
package track
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
@ -299,6 +300,12 @@ func (h *TrackHandler) StreamTrack(c *gin.Context) {
|
||||||
h.respondWithError(c, http.StatusForbidden, "invalid share token")
|
h.respondWithError(c, http.StatusForbidden, "invalid share token")
|
||||||
return
|
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 {
|
} else if !track.IsPublic && track.UserID != userID {
|
||||||
h.respondWithError(c, http.StatusForbidden, "forbidden")
|
h.respondWithError(c, http.StatusForbidden, "forbidden")
|
||||||
return
|
return
|
||||||
|
|
@ -353,6 +360,34 @@ func (h *TrackHandler) StreamTrack(c *gin.Context) {
|
||||||
http.ServeContent(c.Writer, c.Request, track.Title, stat.ModTime(), file)
|
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
|
// getContentType retourne le Content-Type approprié pour un format audio
|
||||||
func getContentType(format string) string {
|
func getContentType(format string) string {
|
||||||
switch strings.ToUpper(format) {
|
switch strings.ToUpper(format) {
|
||||||
|
|
|
||||||
|
|
@ -394,6 +394,12 @@ func (h *MarketplaceHandler) GetProduct(c *gin.Context) {
|
||||||
|
|
||||||
// StreamProductPreview streams the first audio preview for a product (v0.401 M1)
|
// StreamProductPreview streams the first audio preview for a product (v0.401 M1)
|
||||||
// GET /marketplace/products/:id/preview
|
// 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) {
|
func (h *MarketplaceHandler) StreamProductPreview(c *gin.Context) {
|
||||||
productID, err := uuid.Parse(c.Param("id"))
|
productID, err := uuid.Parse(c.Param("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -401,10 +407,13 @@ func (h *MarketplaceHandler) StreamProductPreview(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
product, err := h.service.GetProduct(c.Request.Context(), productID)
|
product, err := h.service.GetProduct(c.Request.Context(), productID)
|
||||||
if err != nil || len(product.Previews) == 0 {
|
if err != nil {
|
||||||
response.NotFound(c, "Preview not found")
|
response.NotFound(c, "Product not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Path 1 : file-based preview already uploaded (legacy path).
|
||||||
|
if len(product.Previews) > 0 {
|
||||||
preview := product.Previews[0]
|
preview := product.Previews[0]
|
||||||
fullPath := filepath.Join(h.uploadDir, preview.FilePath)
|
fullPath := filepath.Join(h.uploadDir, preview.FilePath)
|
||||||
if _, err := os.Stat(fullPath); os.IsNotExist(err) {
|
if _, err := os.Stat(fullPath); os.IsNotExist(err) {
|
||||||
|
|
@ -412,7 +421,30 @@ func (h *MarketplaceHandler) StreamProductPreview(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.Header("Content-Disposition", "inline")
|
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)
|
c.File(fullPath)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
// UpdateProductImagesRequest body for PUT /products/:id/images
|
||||||
|
|
|
||||||
26
veza-backend-api/migrations/989_products_preview_enabled.sql
Normal file
26
veza-backend-api/migrations/989_products_preview_enabled.sql
Normal file
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
Loading…
Reference in a new issue