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>
586 lines
16 KiB
TypeScript
586 lines
16 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
import {
|
|
formatTime,
|
|
calculateProgress,
|
|
isValidTrack,
|
|
findTrackIndex,
|
|
shuffleTracks,
|
|
AudioPlayerService,
|
|
} from './playerService';
|
|
import type { Track } from '../types';
|
|
|
|
describe('playerService', () => {
|
|
describe('formatTime', () => {
|
|
it('should format seconds to MM:SS', () => {
|
|
expect(formatTime(0)).toBe('0:00');
|
|
expect(formatTime(30)).toBe('0:30');
|
|
expect(formatTime(65)).toBe('1:05');
|
|
expect(formatTime(125)).toBe('2:05');
|
|
expect(formatTime(3661)).toBe('61:01');
|
|
});
|
|
|
|
it('should handle invalid values', () => {
|
|
expect(formatTime(NaN)).toBe('0:00');
|
|
expect(formatTime(Infinity)).toBe('0:00');
|
|
expect(formatTime(-1)).toBe('0:00');
|
|
});
|
|
|
|
it('should round down to nearest second', () => {
|
|
expect(formatTime(65.7)).toBe('1:05');
|
|
expect(formatTime(125.9)).toBe('2:05');
|
|
});
|
|
});
|
|
|
|
describe('calculateProgress', () => {
|
|
it('should calculate progress percentage', () => {
|
|
expect(calculateProgress(0, 100)).toBe(0);
|
|
expect(calculateProgress(50, 100)).toBe(50);
|
|
expect(calculateProgress(100, 100)).toBe(100);
|
|
expect(calculateProgress(25, 200)).toBe(12.5);
|
|
});
|
|
|
|
it('should return 0 for invalid duration', () => {
|
|
expect(calculateProgress(50, 0)).toBe(0);
|
|
expect(calculateProgress(50, NaN)).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('isValidTrack', () => {
|
|
it('should return true for valid track', () => {
|
|
const track: Track = {
|
|
id: 1,
|
|
title: 'Test Track',
|
|
duration: 180,
|
|
url: 'https://example.com/track.mp3',
|
|
};
|
|
|
|
expect(isValidTrack(track)).toBe(true);
|
|
});
|
|
|
|
it('should return false for null track', () => {
|
|
expect(isValidTrack(null)).toBe(false);
|
|
});
|
|
|
|
it('should return false for track without id', () => {
|
|
const track = {
|
|
title: 'Test Track',
|
|
duration: 180,
|
|
url: 'https://example.com/track.mp3',
|
|
} as Track;
|
|
|
|
expect(isValidTrack(track)).toBe(false);
|
|
});
|
|
|
|
it('should return false for track without title', () => {
|
|
const track = {
|
|
id: 1,
|
|
duration: 180,
|
|
url: 'https://example.com/track.mp3',
|
|
} as Track;
|
|
|
|
expect(isValidTrack(track)).toBe(false);
|
|
});
|
|
|
|
it('should return false for track without url', () => {
|
|
const track = {
|
|
id: 1,
|
|
title: 'Test Track',
|
|
duration: 180,
|
|
} as Track;
|
|
|
|
expect(isValidTrack(track)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('findTrackIndex', () => {
|
|
it('should find track index in queue', () => {
|
|
const queue: Track[] = [
|
|
{ id: 1, title: 'Track 1', duration: 180, url: 'url1' },
|
|
{ id: 2, title: 'Track 2', duration: 200, url: 'url2' },
|
|
{ id: 3, title: 'Track 3', duration: 220, url: 'url3' },
|
|
];
|
|
|
|
expect(findTrackIndex(queue, 2)).toBe(1);
|
|
expect(findTrackIndex(queue, 1)).toBe(0);
|
|
expect(findTrackIndex(queue, 3)).toBe(2);
|
|
});
|
|
|
|
it('should return -1 if track not found', () => {
|
|
const queue: Track[] = [
|
|
{ id: 1, title: 'Track 1', duration: 180, url: 'url1' },
|
|
];
|
|
|
|
expect(findTrackIndex(queue, 999)).toBe(-1);
|
|
});
|
|
|
|
it('should return -1 for empty queue', () => {
|
|
expect(findTrackIndex([], 1)).toBe(-1);
|
|
});
|
|
});
|
|
|
|
describe('shuffleTracks', () => {
|
|
it('should shuffle tracks array', () => {
|
|
const tracks: Track[] = [
|
|
{ id: 1, title: 'Track 1', duration: 180, url: 'url1' },
|
|
{ id: 2, title: 'Track 2', duration: 200, url: 'url2' },
|
|
{ id: 3, title: 'Track 3', duration: 220, url: 'url3' },
|
|
{ id: 4, title: 'Track 4', duration: 240, url: 'url4' },
|
|
{ id: 5, title: 'Track 5', duration: 260, url: 'url5' },
|
|
];
|
|
|
|
const shuffled = shuffleTracks(tracks);
|
|
|
|
// Should have same length
|
|
expect(shuffled.length).toBe(tracks.length);
|
|
|
|
// Should have same tracks (by id)
|
|
const originalIds = tracks.map((t) => t.id).sort();
|
|
const shuffledIds = shuffled.map((t) => t.id).sort();
|
|
expect(shuffledIds).toEqual(originalIds);
|
|
});
|
|
|
|
it('should handle empty array', () => {
|
|
expect(shuffleTracks([])).toEqual([]);
|
|
});
|
|
|
|
it('should handle single track', () => {
|
|
const tracks: Track[] = [
|
|
{ id: 1, title: 'Track 1', duration: 180, url: 'url1' },
|
|
];
|
|
|
|
const shuffled = shuffleTracks(tracks);
|
|
expect(shuffled).toEqual(tracks);
|
|
});
|
|
|
|
it('should not mutate original array', () => {
|
|
const tracks: Track[] = [
|
|
{ id: 1, title: 'Track 1', duration: 180, url: 'url1' },
|
|
{ id: 2, title: 'Track 2', duration: 200, url: 'url2' },
|
|
];
|
|
|
|
const original = [...tracks];
|
|
shuffleTracks(tracks);
|
|
|
|
expect(tracks).toEqual(original);
|
|
});
|
|
});
|
|
|
|
describe('AudioPlayerService', () => {
|
|
let audioElement: HTMLAudioElement;
|
|
let service: AudioPlayerService;
|
|
|
|
beforeEach(() => {
|
|
audioElement = document.createElement('audio');
|
|
service = new AudioPlayerService();
|
|
});
|
|
|
|
describe('Initialization', () => {
|
|
it('should initialize with audio element', () => {
|
|
service.initialize(audioElement);
|
|
expect(service.getCurrentTime()).toBe(0);
|
|
});
|
|
|
|
it('should throw error if not initialized', async () => {
|
|
await expect(service.play()).rejects.toThrow(
|
|
'Audio element not initialized',
|
|
);
|
|
expect(() => service.pause()).toThrow('Audio element not initialized');
|
|
expect(() => service.seek(10)).toThrow('Audio element not initialized');
|
|
});
|
|
});
|
|
|
|
describe('Load track', () => {
|
|
beforeEach(() => {
|
|
service.initialize(audioElement);
|
|
});
|
|
|
|
it('should load valid track', async () => {
|
|
const track: Track = {
|
|
id: 1,
|
|
title: 'Test Track',
|
|
duration: 180,
|
|
url: 'https://example.com/track.mp3',
|
|
};
|
|
|
|
await service.loadTrack(track);
|
|
|
|
expect(audioElement.src).toContain('example.com/track.mp3');
|
|
});
|
|
|
|
it('should clear src when loading null track', async () => {
|
|
const track: Track = {
|
|
id: 1,
|
|
title: 'Test Track',
|
|
duration: 180,
|
|
url: 'https://example.com/track.mp3',
|
|
};
|
|
|
|
await service.loadTrack(track);
|
|
expect(audioElement.src).toContain('example.com/track.mp3');
|
|
|
|
await service.loadTrack(null);
|
|
|
|
// After loading null, src should be cleared (empty or about:blank)
|
|
const srcAfterClear = audioElement.src;
|
|
expect(
|
|
srcAfterClear === '' ||
|
|
srcAfterClear === 'about:blank' ||
|
|
srcAfterClear === window.location.href,
|
|
).toBe(true);
|
|
});
|
|
|
|
it('should throw error for invalid track', async () => {
|
|
const invalidTrack = {
|
|
id: 1,
|
|
title: '',
|
|
duration: 180,
|
|
url: '',
|
|
} as Track;
|
|
|
|
await expect(service.loadTrack(invalidTrack)).rejects.toThrow(
|
|
'Invalid track',
|
|
);
|
|
});
|
|
|
|
it('should fallback to direct stream URL when track has invalid media URL', async () => {
|
|
const trackWithInvalidUrl = {
|
|
id: 1,
|
|
title: 'Test',
|
|
duration: 180,
|
|
url: 'undefined',
|
|
} as Track;
|
|
|
|
await service.loadTrack(trackWithInvalidUrl);
|
|
|
|
// When URL is invalid (e.g. 'undefined'), the service falls back to the
|
|
// /api/v1/tracks/:id/stream endpoint (always on, Range-aware).
|
|
const srcAfterFallback = audioElement.src;
|
|
expect(srcAfterFallback).toContain('/api/v1/tracks/');
|
|
expect(srcAfterFallback).toContain('/stream');
|
|
});
|
|
});
|
|
|
|
describe('Playback control', () => {
|
|
beforeEach(() => {
|
|
service.initialize(audioElement);
|
|
});
|
|
|
|
it('should play audio', async () => {
|
|
// Mock play method
|
|
const playPromise = Promise.resolve();
|
|
audioElement.play = vi.fn().mockReturnValue(playPromise);
|
|
|
|
await service.play();
|
|
|
|
expect(audioElement.play).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should pause audio', () => {
|
|
audioElement.pause = vi.fn();
|
|
|
|
service.pause();
|
|
|
|
expect(audioElement.pause).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should stop audio', () => {
|
|
audioElement.pause = vi.fn();
|
|
audioElement.currentTime = 50;
|
|
|
|
service.stop();
|
|
|
|
expect(audioElement.pause).toHaveBeenCalled();
|
|
expect(audioElement.currentTime).toBe(0);
|
|
});
|
|
|
|
it('should handle play error', async () => {
|
|
const error = new Error('Play failed');
|
|
audioElement.play = vi.fn().mockRejectedValue(error);
|
|
|
|
await expect(service.play()).rejects.toThrow('Failed to play audio');
|
|
});
|
|
});
|
|
|
|
describe('Seek', () => {
|
|
beforeEach(() => {
|
|
service.initialize(audioElement);
|
|
Object.defineProperty(audioElement, 'duration', {
|
|
value: 180,
|
|
writable: true,
|
|
configurable: true,
|
|
});
|
|
});
|
|
|
|
it('should seek to position', () => {
|
|
service.seek(50);
|
|
|
|
expect(audioElement.currentTime).toBe(50);
|
|
});
|
|
|
|
it('should clamp seek to duration', () => {
|
|
service.seek(200);
|
|
|
|
expect(audioElement.currentTime).toBe(180);
|
|
});
|
|
|
|
it('should not allow negative seek', () => {
|
|
service.seek(-10);
|
|
|
|
expect(audioElement.currentTime).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('Volume control', () => {
|
|
beforeEach(() => {
|
|
service.initialize(audioElement);
|
|
});
|
|
|
|
it('should set volume', () => {
|
|
service.setVolume(0.5);
|
|
|
|
expect(audioElement.volume).toBe(0.5);
|
|
});
|
|
|
|
it('should clamp volume to 0-1', () => {
|
|
service.setVolume(1.5);
|
|
expect(audioElement.volume).toBe(1);
|
|
|
|
service.setVolume(-0.5);
|
|
expect(audioElement.volume).toBe(0);
|
|
});
|
|
|
|
it('should set muted', () => {
|
|
service.setMuted(true);
|
|
expect(audioElement.muted).toBe(true);
|
|
|
|
service.setMuted(false);
|
|
expect(audioElement.muted).toBe(false);
|
|
});
|
|
|
|
it('should get volume', () => {
|
|
audioElement.volume = 0.75;
|
|
expect(service.getVolume()).toBe(0.75);
|
|
});
|
|
|
|
it('should get muted state', () => {
|
|
audioElement.muted = true;
|
|
expect(service.isMuted()).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('State getters', () => {
|
|
beforeEach(() => {
|
|
service.initialize(audioElement);
|
|
});
|
|
|
|
it('should get current time', () => {
|
|
audioElement.currentTime = 45;
|
|
expect(service.getCurrentTime()).toBe(45);
|
|
});
|
|
|
|
it('should get duration', () => {
|
|
Object.defineProperty(audioElement, 'duration', {
|
|
value: 180,
|
|
writable: true,
|
|
configurable: true,
|
|
});
|
|
expect(service.getDuration()).toBe(180);
|
|
});
|
|
|
|
it('should check if playing', () => {
|
|
Object.defineProperty(audioElement, 'paused', {
|
|
value: false,
|
|
writable: true,
|
|
configurable: true,
|
|
});
|
|
Object.defineProperty(audioElement, 'ended', {
|
|
value: false,
|
|
writable: true,
|
|
configurable: true,
|
|
});
|
|
expect(service.isPlaying()).toBe(true);
|
|
|
|
Object.defineProperty(audioElement, 'paused', {
|
|
value: true,
|
|
writable: true,
|
|
configurable: true,
|
|
});
|
|
expect(service.isPlaying()).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('Event callbacks', () => {
|
|
beforeEach(() => {
|
|
service.initialize(audioElement);
|
|
});
|
|
|
|
it('should call timeUpdate callback', () => {
|
|
const callback = vi.fn();
|
|
service.onTimeUpdate(callback);
|
|
|
|
audioElement.currentTime = 10;
|
|
audioElement.dispatchEvent(new Event('timeupdate'));
|
|
|
|
expect(callback).toHaveBeenCalledWith(10);
|
|
});
|
|
|
|
it('should call durationChange callback', () => {
|
|
const callback = vi.fn();
|
|
service.onDurationChange(callback);
|
|
|
|
Object.defineProperty(audioElement, 'duration', {
|
|
value: 180,
|
|
writable: true,
|
|
configurable: true,
|
|
});
|
|
audioElement.dispatchEvent(new Event('loadedmetadata'));
|
|
|
|
expect(callback).toHaveBeenCalledWith(180);
|
|
});
|
|
|
|
it('should call ended callback', () => {
|
|
const callback = vi.fn();
|
|
service.onEnded(callback);
|
|
|
|
audioElement.dispatchEvent(new Event('ended'));
|
|
|
|
expect(callback).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should call error callback', () => {
|
|
const callback = vi.fn();
|
|
service.onError(callback);
|
|
|
|
const errorEvent = new Event('error');
|
|
Object.defineProperty(audioElement, 'error', {
|
|
value: { message: 'Test error' },
|
|
writable: true,
|
|
});
|
|
audioElement.dispatchEvent(errorEvent);
|
|
|
|
expect(callback).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should call play callback', () => {
|
|
const callback = vi.fn();
|
|
service.onPlay(callback);
|
|
|
|
audioElement.dispatchEvent(new Event('play'));
|
|
|
|
expect(callback).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should call pause callback', () => {
|
|
const callback = vi.fn();
|
|
service.onPause(callback);
|
|
|
|
audioElement.dispatchEvent(new Event('pause'));
|
|
|
|
expect(callback).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('Cleanup', () => {
|
|
it('should cleanup event listeners', () => {
|
|
service.initialize(audioElement);
|
|
const callback = vi.fn();
|
|
service.onTimeUpdate(callback);
|
|
|
|
service.cleanup();
|
|
|
|
audioElement.dispatchEvent(new Event('timeupdate'));
|
|
expect(callback).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
// v0.13.1 TASK-AUDIO-003: Normalization
|
|
describe('Audio normalization', () => {
|
|
it('should have normalization enabled by default', () => {
|
|
expect(service.isNormalizationEnabled()).toBe(true);
|
|
});
|
|
|
|
it('should toggle normalization', () => {
|
|
service.setNormalizationEnabled(false);
|
|
expect(service.isNormalizationEnabled()).toBe(false);
|
|
|
|
service.setNormalizationEnabled(true);
|
|
expect(service.isNormalizationEnabled()).toBe(true);
|
|
});
|
|
|
|
it('should set normalization gain', () => {
|
|
// Should not throw even without audio graph connected
|
|
service.setNormalizationGain(-3);
|
|
service.setNormalizationGain(0);
|
|
service.setNormalizationGain(5);
|
|
});
|
|
|
|
it('should return null gain node when not connected', () => {
|
|
expect(service.getGainNode()).toBeNull();
|
|
});
|
|
});
|
|
|
|
// v0.13.1 TASK-AUDIO-001: Gapless preloading
|
|
describe('Gapless preloading', () => {
|
|
beforeEach(() => {
|
|
service.initialize(audioElement);
|
|
});
|
|
|
|
it('should preload a track URL', () => {
|
|
service.preloadTrack('https://example.com/next.mp3');
|
|
expect(service.hasPreloadFor('https://example.com/next.mp3')).toBe(true);
|
|
});
|
|
|
|
it('should not preload invalid URLs', () => {
|
|
service.preloadTrack('');
|
|
service.preloadTrack('undefined');
|
|
expect(service.hasPreloadFor('')).toBe(false);
|
|
});
|
|
|
|
it('should consume preloaded track', () => {
|
|
service.preloadTrack('https://example.com/next.mp3');
|
|
const url = service.consumePreload();
|
|
expect(url).toContain('example.com/next.mp3');
|
|
});
|
|
});
|
|
|
|
// v0.13.1 TASK-AUDIO-004: Playback speed
|
|
describe('Playback speed', () => {
|
|
beforeEach(() => {
|
|
service.initialize(audioElement);
|
|
});
|
|
|
|
it('should set playback rate', () => {
|
|
service.setPlaybackRate(1.5);
|
|
expect(audioElement.playbackRate).toBe(1.5);
|
|
});
|
|
|
|
it('should clamp playback rate to 0.5-2', () => {
|
|
service.setPlaybackRate(3);
|
|
expect(audioElement.playbackRate).toBe(2);
|
|
|
|
service.setPlaybackRate(0.1);
|
|
expect(audioElement.playbackRate).toBe(0.5);
|
|
});
|
|
});
|
|
|
|
// v0.13.1 TASK-AUDIO-002: Fade in/out
|
|
describe('Crossfade', () => {
|
|
beforeEach(() => {
|
|
service.initialize(audioElement);
|
|
audioElement.volume = 1;
|
|
});
|
|
|
|
it('should call onComplete immediately if seconds <= 0', async () => {
|
|
const onComplete = vi.fn();
|
|
await service.fadeOut(0, onComplete);
|
|
expect(onComplete).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should set volume to target after fadeIn with 0 seconds', async () => {
|
|
audioElement.volume = 0;
|
|
await service.fadeIn(0, 0.8);
|
|
expect(audioElement.volume).toBe(0.8);
|
|
});
|
|
});
|
|
});
|
|
});
|