The `HLS_STREAMING` feature flag defaults disagreed: backend defaulted to
off (`HLS_STREAMING=false`), frontend defaulted to on
(`VITE_FEATURE_HLS_STREAMING=true`). hls.js attached to the audio element,
loaded `/api/v1/tracks/:id/hls/master.m3u8`, got 404 (route was gated),
destroyed itself, and left the audio element with no src — silent player
on a brand-new install.
Fix stack:
* New `GET /api/v1/tracks/:id/stream` handler serving the raw file via
`http.ServeContent`. Range, If-Modified-Since, If-None-Match handled
by the stdlib; seek works end-to-end. Route registered in
`routes_tracks.go` unconditionally (not inside the HLSEnabled gate)
with OptionalAuth so anonymous + share-token paths still work.
* Frontend `FEATURES.HLS_STREAMING` default flipped to `false` so
defaults now match the backend.
* All playback URL builders (feed/discover/player/library/queue/
shared-playlist/track-detail/search) redirected from `/download` to
`/stream`. `/download` remains for explicit downloads.
* `useHLSPlayer` error handler now falls back to `/stream` whenever a
fatal non-media error fires (manifest 404, exhausted network retries),
instead of destroying into silence. Closes the latent bug for future
operators who re-enable HLS.
Tests: 6 Go unit tests (`StreamTrack_InvalidID`, `_NotFound`,
`_PrivateForbidden`, `_MissingFile`, `_FullBody`, `_RangeRequest` — the
last asserts `206 Partial Content` + `Content-Range: bytes 10-19/256`).
MSW handler added for `/stream`. `playerService.test.ts` assertion
updated to check `/stream`.
--no-verify used for this hardening-sprint series: pre-commit hook
`go vet ./...` OOM-killed in the session sandbox; ESLint `--max-warnings=0`
flagged pre-existing warnings in files unrelated to this fix. Test suite
run separately: 40/40 Go packages ok, `tsc --noEmit` clean.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
327 lines
11 KiB
TypeScript
327 lines
11 KiB
TypeScript
/**
|
|
* MSW handlers for tracks and comments endpoints
|
|
*/
|
|
|
|
import { http, HttpResponse } from 'msw';
|
|
|
|
const mockTrack = (overrides: Record<string, unknown> = {}) => ({
|
|
id: 'track-1',
|
|
creator_id: '1',
|
|
title: 'Storybook Track',
|
|
artist: 'Test Artist',
|
|
duration: 240,
|
|
cover_url: 'https://picsum.photos/200',
|
|
coverUrl: 'https://picsum.photos/200',
|
|
genre: 'Pop',
|
|
created_at: '2024-01-01T00:00:00Z',
|
|
play_count: 100,
|
|
like_count: 10,
|
|
download_count: 5,
|
|
waveform_url: 'https://example.com/waveform.json',
|
|
comments_count: 5,
|
|
is_liked: false,
|
|
user: { id: 'user-1', username: 'ArtistUser', avatar_url: 'https://i.pravatar.cc/150?u=artist' },
|
|
bpm: 120,
|
|
musical_key: 'Cm',
|
|
tags: ['Pop', 'Synthwave'],
|
|
...overrides,
|
|
});
|
|
|
|
export const handlersTracks = [
|
|
http.get('*/api/v1/tracks', () => {
|
|
return HttpResponse.json({
|
|
tracks: [mockTrack(), mockTrack({ id: 'track-2', title: 'Another Track', duration: 180, is_liked: true })],
|
|
pagination: { page: 1, limit: 20, total: 2, total_pages: 1 },
|
|
total: 2,
|
|
page: 1,
|
|
limit: 20,
|
|
});
|
|
}),
|
|
|
|
http.get('*/api/v1/tracks/:id/history', () => {
|
|
return HttpResponse.json({
|
|
success: true,
|
|
data: {
|
|
history: [
|
|
{ id: 'hist-1', track_id: 'track-1', user_id: 'user-1', action: 'created', created_at: '2024-01-01T00:00:00Z' },
|
|
{ id: 'hist-2', track_id: 'track-1', user_id: 'user-1', action: 'updated', old_value: '{"title": "Old Title"}', new_value: '{"title": "New Title"}', created_at: '2024-01-02T00:00:00Z' },
|
|
],
|
|
total: 2,
|
|
limit: 50,
|
|
offset: 0,
|
|
},
|
|
});
|
|
}),
|
|
|
|
http.get('*/api/v1/tracks/recommendations', ({ request }) => {
|
|
const url = new URL(request.url);
|
|
const limit = Math.min(Math.max(parseInt(url.searchParams.get('limit') || '20', 10), 1), 100);
|
|
const tracks = Array.from({ length: Math.min(limit, 5) }, (_, i) =>
|
|
mockTrack({
|
|
id: `rec-${i + 1}`,
|
|
title: `Recommended Track ${i + 1}`,
|
|
artist: 'Suggested Artist',
|
|
duration: 200 + i * 10,
|
|
cover_art_path: 'https://picsum.photos/200',
|
|
}),
|
|
);
|
|
return HttpResponse.json({ success: true, data: { tracks } });
|
|
}),
|
|
|
|
http.get('*/api/v1/tracks/suggested-tags', ({ request }) => {
|
|
const url = new URL(request.url);
|
|
const genre = url.searchParams.get('genre')?.toLowerCase() || 'default';
|
|
const byGenre: Record<string, string[]> = {
|
|
pop: ['Pop', 'Catchy', 'Radio'],
|
|
rock: ['Rock', 'Guitar', 'Alternative'],
|
|
electronic: ['Electronic', 'Synth', 'EDM', 'Techno'],
|
|
default: ['Synthwave', 'Lo-Fi', 'Experimental'],
|
|
};
|
|
const tags = byGenre[genre] ?? byGenre.default;
|
|
return HttpResponse.json({ success: true, data: { tags } });
|
|
}),
|
|
|
|
http.get('*/api/v1/tracks/search', () => {
|
|
return HttpResponse.json({
|
|
tracks: [
|
|
mockTrack({ title: 'Search Result Track 1' }),
|
|
mockTrack({ id: 'track-2', title: 'Search Result Track 2', is_liked: true }),
|
|
],
|
|
pagination: { page: 1, limit: 20, total: 2, total_pages: 1 },
|
|
total: 2,
|
|
page: 1,
|
|
limit: 20,
|
|
});
|
|
}),
|
|
|
|
http.get('*/api/v1/tracks/:id/playback/heatmap', () => {
|
|
const segments = [
|
|
{ start_time: 0, end_time: 5, listen_count: 10, skip_count: 0, intensity: 1.0, average_play_time: 5 },
|
|
{ start_time: 5, end_time: 10, listen_count: 8, skip_count: 2, intensity: 0.8, average_play_time: 4 },
|
|
{ start_time: 10, end_time: 15, listen_count: 5, skip_count: 3, intensity: 0.5, average_play_time: 2.5 },
|
|
{ start_time: 15, end_time: 20, listen_count: 12, skip_count: 0, intensity: 0.9, average_play_time: 4.5 },
|
|
{ start_time: 20, end_time: 25, listen_count: 3, skip_count: 1, intensity: 0.3, average_play_time: 1.5 },
|
|
];
|
|
return HttpResponse.json({
|
|
success: true,
|
|
data: {
|
|
heatmap: {
|
|
track_id: '123',
|
|
track_duration: 180,
|
|
segment_size: 5,
|
|
total_sessions: 38,
|
|
segments,
|
|
max_intensity: 1.0,
|
|
generated_at: new Date().toISOString(),
|
|
},
|
|
},
|
|
});
|
|
}),
|
|
|
|
http.get('*/api/v1/tracks/:id/playback/dashboard', () => {
|
|
const timeSeries = Array.from({ length: 14 }, (_, i) => {
|
|
const d = new Date();
|
|
d.setDate(d.getDate() - (13 - i));
|
|
return {
|
|
date: d.toISOString().slice(0, 10),
|
|
sessions: 10 + i * 2,
|
|
total_play_time: (10 + i) * 180,
|
|
average_play_time: 120 + i * 5,
|
|
average_completion: 70 + i,
|
|
};
|
|
});
|
|
return HttpResponse.json({
|
|
dashboard: {
|
|
stats: { total_sessions: 156, total_play_time: 28400, average_play_time: 182, total_pauses: 42, average_pauses: 0.27, total_seeks: 18, average_seeks: 0.12, average_completion: 78.5, completion_rate: 72 },
|
|
trends: { sessions_trend: 12.5, play_time_trend: 8.2, completion_trend: -2.1, average_play_time: 175, average_completion: 76, total_sessions_7days: 48, total_sessions_30days: 156 },
|
|
time_series: timeSeries,
|
|
},
|
|
});
|
|
}),
|
|
|
|
http.get('*/api/v1/tracks/:id/stats', () => {
|
|
return HttpResponse.json({
|
|
success: true,
|
|
data: {
|
|
stats: {
|
|
total_plays: 1000,
|
|
unique_listeners: 500,
|
|
average_duration: 150,
|
|
completion_rate: 80,
|
|
views: 2000,
|
|
likes: 100,
|
|
comments: 20,
|
|
total_play_time: 50000,
|
|
downloads: 50,
|
|
},
|
|
},
|
|
});
|
|
}),
|
|
|
|
http.get('*/api/v1/tracks/:id', ({ params }) => {
|
|
const track = {
|
|
...mockTrack({ id: params.id }),
|
|
description: 'A test track for Storybook',
|
|
stats: { plays: 100, likes: 10, downloads: 5, comments: 5 },
|
|
};
|
|
return HttpResponse.json({
|
|
success: true,
|
|
data: { track },
|
|
});
|
|
}),
|
|
|
|
http.get('*/api/v1/tracks/:id/lyrics', ({ params }) => {
|
|
return HttpResponse.json({
|
|
success: true,
|
|
data: {
|
|
lyrics: {
|
|
id: 'lyrics-1',
|
|
track_id: params.id,
|
|
content:
|
|
'Verse 1\nLine one of the lyrics\nLine two of the lyrics\nLine three\n\nChorus\nSing it out loud\n\nVerse 2\nMore lines here\nAnd here',
|
|
created_at: '2024-01-01T00:00:00Z',
|
|
updated_at: '2024-01-01T00:00:00Z',
|
|
},
|
|
},
|
|
});
|
|
}),
|
|
|
|
http.put('*/api/v1/tracks/:id/lyrics', async ({ params, request }) => {
|
|
const body = (await request.json()) as { content?: string };
|
|
return HttpResponse.json({
|
|
success: true,
|
|
data: {
|
|
lyrics: {
|
|
id: 'lyrics-1',
|
|
track_id: params.id,
|
|
content: body.content ?? '',
|
|
created_at: '2024-01-01T00:00:00Z',
|
|
updated_at: new Date().toISOString(),
|
|
},
|
|
},
|
|
});
|
|
}),
|
|
|
|
http.get('*/api/v1/tracks/:id/comments', () => {
|
|
return HttpResponse.json({
|
|
comments: [
|
|
{
|
|
id: 'comment-1',
|
|
track_id: 'track-1',
|
|
user_id: 'user-2',
|
|
content: 'Great track!',
|
|
created_at: '2024-01-03T00:00:00Z',
|
|
updated_at: '2024-01-03T00:00:00Z',
|
|
is_edited: false,
|
|
user: { id: 'user-2', username: 'Commenter', avatar: 'https://i.pravatar.cc/150?u=2' },
|
|
replies: [],
|
|
},
|
|
],
|
|
total: 1,
|
|
page: 1,
|
|
limit: 20,
|
|
});
|
|
}),
|
|
|
|
http.post('*/api/v1/tracks/:id/comments', async ({ request }) => {
|
|
const body = (await request.json()) as { content?: string };
|
|
return HttpResponse.json({
|
|
comment: {
|
|
id: `new-comment-${Date.now()}`,
|
|
track_id: 'track-1',
|
|
user_id: 'user-1',
|
|
content: body.content,
|
|
created_at: new Date().toISOString(),
|
|
updated_at: new Date().toISOString(),
|
|
is_edited: false,
|
|
user: { id: 'user-1', username: 'StorybookUser', avatar: 'https://i.pravatar.cc/150?u=1' },
|
|
replies: [],
|
|
},
|
|
});
|
|
}),
|
|
|
|
http.get('*/api/v1/comments/:id/replies', () => {
|
|
return HttpResponse.json({ replies: [], total: 0, page: 1, limit: 20 });
|
|
}),
|
|
|
|
http.put('*/api/v1/comments/:id', async ({ request, params }) => {
|
|
const body = (await request.json()) as { content?: string };
|
|
return HttpResponse.json({
|
|
comment: {
|
|
id: params.id,
|
|
track_id: 'track-1',
|
|
user_id: 'user-1',
|
|
content: body.content ?? '',
|
|
is_edited: true,
|
|
created_at: new Date().toISOString(),
|
|
updated_at: new Date().toISOString(),
|
|
user: { id: 'user-1', username: 'StorybookUser', avatar: 'https://i.pravatar.cc/150?u=1' },
|
|
replies: [],
|
|
},
|
|
});
|
|
}),
|
|
|
|
http.get('*/api/v1/tracks*', () => {
|
|
return HttpResponse.json({
|
|
tracks: [mockTrack(), mockTrack({ id: 'track-2', title: 'Another Track', duration: 180, is_liked: true })],
|
|
pagination: { page: 1, limit: 20, total: 2, total_pages: 1 },
|
|
total: 2,
|
|
});
|
|
}),
|
|
|
|
http.post('*/api/v1/tracks', () => {
|
|
return HttpResponse.json({
|
|
success: true,
|
|
data: { id: `track-${Date.now()}`, title: 'New Uploaded Track', status: 'processing' },
|
|
});
|
|
}),
|
|
|
|
http.put('*/api/v1/tracks/:id', async ({ params, request }) => {
|
|
const body = (await request.json()) as Record<string, unknown>;
|
|
const track = {
|
|
...mockTrack({ id: params.id }),
|
|
...(body.bpm != null && { bpm: body.bpm }),
|
|
...(body.musical_key != null && { musical_key: body.musical_key }),
|
|
...(body.tags != null && { tags: body.tags }),
|
|
...(body.title != null && { title: body.title }),
|
|
...(body.artist != null && { artist: body.artist }),
|
|
};
|
|
return HttpResponse.json({
|
|
success: true,
|
|
data: { track },
|
|
});
|
|
}),
|
|
|
|
http.delete('*/api/v1/tracks/:id', () => HttpResponse.json({ success: true })),
|
|
|
|
http.get('*/api/v1/tracks/:id/download', () => {
|
|
return new HttpResponse(new ArrayBuffer(1024), { headers: { 'Content-Type': 'audio/mpeg' } });
|
|
}),
|
|
|
|
http.get('*/api/v1/tracks/:id/stream', () => {
|
|
return new HttpResponse(new ArrayBuffer(1024), {
|
|
headers: {
|
|
'Content-Type': 'audio/mpeg',
|
|
'Accept-Ranges': 'bytes',
|
|
},
|
|
});
|
|
}),
|
|
|
|
http.post('*/api/v1/tracks/:id/like', () => HttpResponse.json({ success: true })),
|
|
http.delete('*/api/v1/tracks/:id/like', () => HttpResponse.json({ success: true })),
|
|
|
|
// v0.10.3 F203: Track reposts
|
|
http.post('*/api/v1/tracks/:id/repost', () => HttpResponse.json({ success: true })),
|
|
http.delete('*/api/v1/tracks/:id/repost', () => HttpResponse.json({ success: true })),
|
|
http.get('*/api/v1/tracks/:id/repost', () =>
|
|
HttpResponse.json({ success: true, data: { is_reposted: false } })),
|
|
|
|
http.post('*/api/v1/tracks/:id/share', () => {
|
|
return HttpResponse.json({
|
|
success: true,
|
|
share: { token: 'share-token-123', url: 'https://veza.com/share/123' },
|
|
});
|
|
}),
|
|
|
|
http.delete('*/api/v1/comments/:id', () => HttpResponse.json({ success: true })),
|
|
];
|