df970da59e
* chore(deps): update dependency eslint-plugin-unicorn to v70 * fix: linting --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
438 lines
18 KiB
TypeScript
438 lines
18 KiB
TypeScript
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
|
import { HlsVideoResolution, VideoCodec } from 'src/enum';
|
|
import { HlsService } from 'src/services/hls.service';
|
|
import { eiffelTower, train, waterfall } from 'test/fixtures/media.stub';
|
|
import { factory } from 'test/small.factory';
|
|
import { newTestService, ServiceMocks } from 'test/utils';
|
|
|
|
// EXTINF values come from FFmpeg's playlist to enforce an exact match
|
|
const eiffelExpectedMediaPlaylist = `#EXTM3U
|
|
#EXT-X-VERSION:7
|
|
#EXT-X-INDEPENDENT-SEGMENTS
|
|
#EXT-X-TARGETDURATION:2
|
|
#EXT-X-MEDIA-SEQUENCE:0
|
|
#EXT-X-PLAYLIST-TYPE:VOD
|
|
#EXT-X-MAP:URI="init.mp4"
|
|
#EXTINF:2.007222,
|
|
seg_0.m4s
|
|
#EXTINF:2.007222,
|
|
seg_1.m4s
|
|
#EXTINF:2.007222,
|
|
seg_2.m4s
|
|
#EXTINF:2.007222,
|
|
seg_3.m4s
|
|
#EXTINF:2.007222,
|
|
seg_4.m4s
|
|
#EXTINF:2.007222,
|
|
seg_5.m4s
|
|
#EXTINF:2.007222,
|
|
seg_6.m4s
|
|
#EXTINF:2.007222,
|
|
seg_7.m4s
|
|
#EXTINF:2.007222,
|
|
seg_8.m4s
|
|
#EXTINF:2.007222,
|
|
seg_9.m4s
|
|
#EXTINF:2.007222,
|
|
seg_10.m4s
|
|
#EXTINF:0.281011,
|
|
seg_11.m4s
|
|
#EXT-X-ENDLIST
|
|
`;
|
|
|
|
const waterfallExpectedMediaPlaylist = `#EXTM3U
|
|
#EXT-X-VERSION:7
|
|
#EXT-X-INDEPENDENT-SEGMENTS
|
|
#EXT-X-TARGETDURATION:2
|
|
#EXT-X-MEDIA-SEQUENCE:0
|
|
#EXT-X-PLAYLIST-TYPE:VOD
|
|
#EXT-X-MAP:URI="init.mp4"
|
|
#EXTINF:2.011405,
|
|
seg_0.m4s
|
|
#EXTINF:2.011405,
|
|
seg_1.m4s
|
|
#EXTINF:2.011405,
|
|
seg_2.m4s
|
|
#EXTINF:2.011405,
|
|
seg_3.m4s
|
|
#EXTINF:2.011405,
|
|
seg_4.m4s
|
|
#EXTINF:0.301711,
|
|
seg_5.m4s
|
|
#EXT-X-ENDLIST
|
|
`;
|
|
|
|
const trainExpectedMediaPlaylist = `#EXTM3U
|
|
#EXT-X-VERSION:7
|
|
#EXT-X-INDEPENDENT-SEGMENTS
|
|
#EXT-X-TARGETDURATION:2
|
|
#EXT-X-MEDIA-SEQUENCE:0
|
|
#EXT-X-PLAYLIST-TYPE:VOD
|
|
#EXT-X-MAP:URI="init.mp4"
|
|
#EXTINF:2.000000,
|
|
seg_0.m4s
|
|
#EXTINF:2.000000,
|
|
seg_1.m4s
|
|
#EXTINF:2.000000,
|
|
seg_2.m4s
|
|
#EXTINF:2.000000,
|
|
seg_3.m4s
|
|
#EXTINF:2.000000,
|
|
seg_4.m4s
|
|
#EXTINF:2.000000,
|
|
seg_5.m4s
|
|
#EXTINF:2.000000,
|
|
seg_6.m4s
|
|
#EXTINF:2.000000,
|
|
seg_7.m4s
|
|
#EXTINF:2.000000,
|
|
seg_8.m4s
|
|
#EXTINF:2.000000,
|
|
seg_9.m4s
|
|
#EXTINF:1.733333,
|
|
seg_10.m4s
|
|
#EXT-X-ENDLIST
|
|
`;
|
|
|
|
const sessionId = '00000000-0000-0000-0000-000000000000';
|
|
|
|
const eiffelExpectedMasterAv1 = `#EXTM3U
|
|
#EXT-X-VERSION:7
|
|
#EXT-X-INDEPENDENT-SEGMENTS
|
|
#EXT-X-STREAM-INF:BANDWIDTH=1350000,RESOLUTION=480x852,CODECS="av01.0.04M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
|
${sessionId}/0/playlist.m3u8
|
|
#EXT-X-STREAM-INF:BANDWIDTH=1620000,RESOLUTION=480x852,CODECS="hvc1.1.6.L90.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
|
${sessionId}/1/playlist.m3u8
|
|
#EXT-X-STREAM-INF:BANDWIDTH=3375000,RESOLUTION=480x852,CODECS="avc1.64001e,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
|
${sessionId}/2/playlist.m3u8
|
|
#EXT-X-STREAM-INF:BANDWIDTH=2700000,RESOLUTION=720x1280,CODECS="av01.0.05M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
|
${sessionId}/3/playlist.m3u8
|
|
#EXT-X-STREAM-INF:BANDWIDTH=3375000,RESOLUTION=720x1280,CODECS="hvc1.1.6.L93.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
|
${sessionId}/4/playlist.m3u8
|
|
#EXT-X-STREAM-INF:BANDWIDTH=6750000,RESOLUTION=720x1280,CODECS="avc1.64001f,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
|
${sessionId}/5/playlist.m3u8
|
|
#EXT-X-STREAM-INF:BANDWIDTH=5400000,RESOLUTION=1080x1920,CODECS="av01.0.08M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
|
${sessionId}/6/playlist.m3u8
|
|
#EXT-X-STREAM-INF:BANDWIDTH=6075000,RESOLUTION=1080x1920,CODECS="hvc1.1.6.L120.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
|
${sessionId}/7/playlist.m3u8
|
|
#EXT-X-STREAM-INF:BANDWIDTH=10800000,RESOLUTION=1080x1920,CODECS="avc1.640028,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
|
${sessionId}/8/playlist.m3u8
|
|
`;
|
|
|
|
const eiffelExpectedMasterNoAv1 = `#EXTM3U
|
|
#EXT-X-VERSION:7
|
|
#EXT-X-INDEPENDENT-SEGMENTS
|
|
#EXT-X-STREAM-INF:BANDWIDTH=1620000,RESOLUTION=480x852,CODECS="hvc1.1.6.L90.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
|
${sessionId}/1/playlist.m3u8
|
|
#EXT-X-STREAM-INF:BANDWIDTH=3375000,RESOLUTION=480x852,CODECS="avc1.64001e,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
|
${sessionId}/2/playlist.m3u8
|
|
#EXT-X-STREAM-INF:BANDWIDTH=3375000,RESOLUTION=720x1280,CODECS="hvc1.1.6.L93.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
|
${sessionId}/4/playlist.m3u8
|
|
#EXT-X-STREAM-INF:BANDWIDTH=6750000,RESOLUTION=720x1280,CODECS="avc1.64001f,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
|
${sessionId}/5/playlist.m3u8
|
|
#EXT-X-STREAM-INF:BANDWIDTH=6075000,RESOLUTION=1080x1920,CODECS="hvc1.1.6.L120.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
|
${sessionId}/7/playlist.m3u8
|
|
#EXT-X-STREAM-INF:BANDWIDTH=10800000,RESOLUTION=1080x1920,CODECS="avc1.640028,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
|
${sessionId}/8/playlist.m3u8
|
|
`;
|
|
|
|
const waterfallExpectedMasterAv1 = `#EXTM3U
|
|
#EXT-X-VERSION:7
|
|
#EXT-X-INDEPENDENT-SEGMENTS
|
|
#EXT-X-STREAM-INF:BANDWIDTH=1350000,RESOLUTION=480x852,CODECS="av01.0.04M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
|
${sessionId}/0/playlist.m3u8
|
|
#EXT-X-STREAM-INF:BANDWIDTH=1620000,RESOLUTION=480x852,CODECS="hvc1.1.6.L90.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
|
${sessionId}/1/playlist.m3u8
|
|
#EXT-X-STREAM-INF:BANDWIDTH=3375000,RESOLUTION=480x852,CODECS="avc1.64001f,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
|
${sessionId}/2/playlist.m3u8
|
|
#EXT-X-STREAM-INF:BANDWIDTH=2700000,RESOLUTION=720x1280,CODECS="av01.0.05M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
|
${sessionId}/3/playlist.m3u8
|
|
#EXT-X-STREAM-INF:BANDWIDTH=3375000,RESOLUTION=720x1280,CODECS="hvc1.1.6.L93.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
|
${sessionId}/4/playlist.m3u8
|
|
#EXT-X-STREAM-INF:BANDWIDTH=6750000,RESOLUTION=720x1280,CODECS="avc1.64001f,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
|
${sessionId}/5/playlist.m3u8
|
|
#EXT-X-STREAM-INF:BANDWIDTH=5400000,RESOLUTION=1080x1920,CODECS="av01.0.08M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
|
${sessionId}/6/playlist.m3u8
|
|
#EXT-X-STREAM-INF:BANDWIDTH=6075000,RESOLUTION=1080x1920,CODECS="hvc1.1.6.L120.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
|
${sessionId}/7/playlist.m3u8
|
|
#EXT-X-STREAM-INF:BANDWIDTH=10800000,RESOLUTION=1080x1920,CODECS="avc1.640028,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
|
${sessionId}/8/playlist.m3u8
|
|
#EXT-X-STREAM-INF:BANDWIDTH=9450000,RESOLUTION=1440x2560,CODECS="av01.0.12M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
|
${sessionId}/9/playlist.m3u8
|
|
#EXT-X-STREAM-INF:BANDWIDTH=10800000,RESOLUTION=1440x2560,CODECS="hvc1.1.6.L150.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
|
${sessionId}/10/playlist.m3u8
|
|
#EXT-X-STREAM-INF:BANDWIDTH=18900000,RESOLUTION=1440x2560,CODECS="avc1.640032,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
|
${sessionId}/11/playlist.m3u8
|
|
#EXT-X-STREAM-INF:BANDWIDTH=16200000,RESOLUTION=2160x3840,CODECS="av01.0.12M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
|
${sessionId}/12/playlist.m3u8
|
|
#EXT-X-STREAM-INF:BANDWIDTH=18900000,RESOLUTION=2160x3840,CODECS="hvc1.1.6.L150.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
|
${sessionId}/13/playlist.m3u8
|
|
#EXT-X-STREAM-INF:BANDWIDTH=33750000,RESOLUTION=2160x3840,CODECS="avc1.640033,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
|
${sessionId}/14/playlist.m3u8
|
|
`;
|
|
|
|
describe(HlsService.name, () => {
|
|
let sut: HlsService;
|
|
let mocks: ServiceMocks;
|
|
|
|
beforeEach(() => {
|
|
({ sut, mocks } = newTestService(HlsService));
|
|
});
|
|
|
|
describe('getMainPlaylist', () => {
|
|
const auth = factory.auth();
|
|
const assetId = 'asset-1';
|
|
|
|
const allCodecs = [VideoCodec.Av1, VideoCodec.Hevc, VideoCodec.H264];
|
|
const allResolutions = [
|
|
HlsVideoResolution.p480,
|
|
HlsVideoResolution.p720,
|
|
HlsVideoResolution.p1080,
|
|
HlsVideoResolution.p1440,
|
|
HlsVideoResolution.p2160,
|
|
];
|
|
|
|
const setup = (
|
|
asset: typeof eiffelTower | typeof waterfall,
|
|
videoCodecs?: VideoCodec[],
|
|
resolutions?: HlsVideoResolution[],
|
|
) => {
|
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId]));
|
|
mocks.systemMetadata.get.mockResolvedValue({
|
|
ffmpeg: { realtime: { enabled: true, videoCodecs, resolutions } },
|
|
});
|
|
mocks.videoStream.getForMainPlaylist.mockResolvedValue(asset);
|
|
mocks.crypto.randomUUID.mockReturnValue(sessionId);
|
|
mocks.websocket.serverSend.mockImplementation((event, ...rest) => {
|
|
if (event !== 'HlsSessionRequest') {
|
|
return;
|
|
}
|
|
|
|
const { sessionId: id } = rest[0] as { sessionId: string };
|
|
queueMicrotask(() => sut.onSessionResult({ sessionId: id }));
|
|
});
|
|
};
|
|
|
|
it('offers AV1, HEVC, and H.264 when AV1 is configured and the accelerator supports it', async () => {
|
|
setup(eiffelTower, allCodecs);
|
|
await expect(sut.getMainPlaylist(auth, assetId)).resolves.toBe(eiffelExpectedMasterAv1);
|
|
});
|
|
|
|
it('omits AV1 when it is not in the configured codecs', async () => {
|
|
setup(eiffelTower);
|
|
await expect(sut.getMainPlaylist(auth, assetId)).resolves.toBe(eiffelExpectedMasterNoAv1);
|
|
});
|
|
|
|
it('offers every resolution up to the source and derives 4K codec levels (waterfall, 4K, 29.83fps)', async () => {
|
|
setup(waterfall, allCodecs, allResolutions);
|
|
await expect(sut.getMainPlaylist(auth, assetId)).resolves.toBe(waterfallExpectedMasterAv1);
|
|
});
|
|
|
|
it('throws BadRequestException when realtime transcoding is disabled', async () => {
|
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId]));
|
|
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { realtime: { enabled: false } } });
|
|
await expect(sut.getMainPlaylist(auth, assetId)).rejects.toBeInstanceOf(BadRequestException);
|
|
});
|
|
|
|
it('throws NotFoundException when asset is not yet ready for streaming', async () => {
|
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId]));
|
|
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { realtime: { enabled: true } } });
|
|
await expect(sut.getMainPlaylist(auth, assetId)).rejects.toBeInstanceOf(NotFoundException);
|
|
});
|
|
});
|
|
|
|
describe('getMediaPlaylist', () => {
|
|
const auth = factory.auth();
|
|
const assetId = 'asset-1';
|
|
const fixtures = [
|
|
{ data: eiffelTower, playlist: eiffelExpectedMediaPlaylist },
|
|
{ data: waterfall, playlist: waterfallExpectedMediaPlaylist },
|
|
{ data: train, playlist: trainExpectedMediaPlaylist },
|
|
];
|
|
|
|
it.each(fixtures)('matches FFmpeg for $data.originalPath', async ({ data, playlist }) => {
|
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId]));
|
|
mocks.videoStream.getForMediaPlaylist.mockResolvedValue(data);
|
|
await expect(sut.getMediaPlaylist(auth, assetId, sessionId, 0)).resolves.toBe(playlist);
|
|
});
|
|
|
|
it('throws NotFoundException when the session/asset cannot be loaded', async () => {
|
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId]));
|
|
await expect(sut.getMediaPlaylist(auth, assetId, sessionId, 0)).rejects.toBeInstanceOf(NotFoundException);
|
|
});
|
|
|
|
it('prewarms transcoding at the segment containing the hinted position', async () => {
|
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId]));
|
|
mocks.videoStream.getForMediaPlaylist.mockResolvedValue(eiffelTower);
|
|
await sut.getMediaPlaylist(auth, assetId, sessionId, 1, 10.5);
|
|
expect(mocks.websocket.serverSend).toHaveBeenCalledWith('HlsSegmentRequest', {
|
|
sessionId,
|
|
assetId,
|
|
variantIndex: 1,
|
|
segmentIndex: 5,
|
|
});
|
|
});
|
|
|
|
it('prewarms from the last requested segment when no hint is given', async () => {
|
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId]));
|
|
mocks.videoStream.getSession.mockResolvedValue({ id: sessionId, assetId } as never);
|
|
mocks.storage.checkFileExists.mockResolvedValue(true);
|
|
await sut.getSegment(auth, assetId, sessionId, 0, 'seg_5.m4s');
|
|
|
|
mocks.videoStream.getForMediaPlaylist.mockResolvedValue(eiffelTower);
|
|
await sut.getMediaPlaylist(auth, assetId, sessionId, 1);
|
|
expect(mocks.websocket.serverSend).toHaveBeenCalledWith('HlsSegmentRequest', {
|
|
sessionId,
|
|
assetId,
|
|
variantIndex: 1,
|
|
segmentIndex: 6,
|
|
});
|
|
});
|
|
|
|
it('does not prewarm without a hint or prior segment traffic', async () => {
|
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId]));
|
|
mocks.videoStream.getForMediaPlaylist.mockResolvedValue(eiffelTower);
|
|
await sut.getMediaPlaylist(auth, assetId, sessionId, 1);
|
|
expect(mocks.websocket.serverSend).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not prewarm the variant the session is already playing', async () => {
|
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId]));
|
|
mocks.videoStream.getSession.mockResolvedValue({ id: sessionId, assetId } as never);
|
|
mocks.storage.checkFileExists.mockResolvedValue(true);
|
|
await sut.getSegment(auth, assetId, sessionId, 1, 'seg_5.m4s');
|
|
|
|
mocks.videoStream.getForMediaPlaylist.mockResolvedValue(eiffelTower);
|
|
await sut.getMediaPlaylist(auth, assetId, sessionId, 1, 12.5);
|
|
expect(mocks.websocket.serverSend).not.toHaveBeenCalledWith('HlsSegmentRequest', expect.anything());
|
|
});
|
|
});
|
|
|
|
describe('getSegment', () => {
|
|
const auth = factory.auth();
|
|
const assetId = 'asset-1';
|
|
const variantIndex = 0;
|
|
|
|
beforeEach(() => {
|
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId]));
|
|
mocks.videoStream.getSession.mockResolvedValue({ id: sessionId, assetId } as never);
|
|
mocks.storage.checkFileExists.mockResolvedValue(true);
|
|
});
|
|
|
|
it('emits HlsHeartbeat with segmentIndex 0 for the first init.mp4 request', async () => {
|
|
await sut.getSegment(auth, assetId, sessionId, variantIndex, 'init.mp4');
|
|
expect(mocks.websocket.serverSend).toHaveBeenCalledWith('HlsHeartbeat', {
|
|
sessionId,
|
|
variantIndex,
|
|
segmentIndex: 0,
|
|
});
|
|
});
|
|
|
|
it('emits HlsHeartbeat with the parsed segment number for seg_K.m4s', async () => {
|
|
await sut.getSegment(auth, assetId, sessionId, variantIndex, 'seg_5.m4s');
|
|
expect(mocks.websocket.serverSend).toHaveBeenCalledWith('HlsHeartbeat', {
|
|
sessionId,
|
|
variantIndex,
|
|
segmentIndex: 5,
|
|
});
|
|
});
|
|
|
|
it('returns lastRequested + 1 for init.mp4 without a target segment', async () => {
|
|
await sut.getSegment(auth, assetId, sessionId, variantIndex, 'seg_5.m4s');
|
|
mocks.websocket.serverSend.mockClear();
|
|
await sut.getSegment(auth, assetId, sessionId, variantIndex, 'init.mp4');
|
|
expect(mocks.websocket.serverSend).toHaveBeenCalledWith('HlsHeartbeat', {
|
|
sessionId,
|
|
variantIndex,
|
|
segmentIndex: 6,
|
|
});
|
|
});
|
|
|
|
it('updates lastRequested on a backward-seek segment request', async () => {
|
|
await sut.getSegment(auth, assetId, sessionId, variantIndex, 'seg_5.m4s');
|
|
await sut.getSegment(auth, assetId, sessionId, variantIndex, 'seg_3.m4s');
|
|
mocks.websocket.serverSend.mockClear();
|
|
await sut.getSegment(auth, assetId, sessionId, variantIndex, 'init.mp4');
|
|
expect(mocks.websocket.serverSend).toHaveBeenCalledWith('HlsHeartbeat', {
|
|
sessionId,
|
|
variantIndex,
|
|
segmentIndex: 4,
|
|
});
|
|
});
|
|
|
|
it('tracks segment state per session independently', async () => {
|
|
await sut.getSegment(auth, assetId, 'session-a', variantIndex, 'seg_5.m4s');
|
|
await sut.getSegment(auth, assetId, 'session-b', variantIndex, 'seg_2.m4s');
|
|
mocks.websocket.serverSend.mockClear();
|
|
await sut.getSegment(auth, assetId, 'session-a', variantIndex, 'init.mp4');
|
|
await sut.getSegment(auth, assetId, 'session-b', variantIndex, 'init.mp4');
|
|
expect(mocks.websocket.serverSend).toHaveBeenCalledWith('HlsHeartbeat', {
|
|
sessionId: 'session-a',
|
|
variantIndex,
|
|
segmentIndex: 6,
|
|
});
|
|
expect(mocks.websocket.serverSend).toHaveBeenCalledWith('HlsHeartbeat', {
|
|
sessionId: 'session-b',
|
|
variantIndex,
|
|
segmentIndex: 3,
|
|
});
|
|
});
|
|
|
|
it('rejects pending waiters for the previous variant on variant change', async () => {
|
|
mocks.storage.checkFileExists.mockResolvedValueOnce(false);
|
|
|
|
const pending = sut.getSegment(auth, assetId, sessionId, 0, 'seg_1.m4s');
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
await sut.getSegment(auth, assetId, sessionId, 1, 'seg_1.m4s');
|
|
|
|
await expect(pending).rejects.toThrow('Variant changed');
|
|
});
|
|
|
|
it('throws NotFoundException when the session does not exist', async () => {
|
|
mocks.videoStream.getSession.mockReset();
|
|
await expect(sut.getSegment(auth, assetId, sessionId, variantIndex, 'init.mp4')).rejects.toBeInstanceOf(
|
|
NotFoundException,
|
|
);
|
|
});
|
|
|
|
it('uses the initSegment hint for init.mp4', async () => {
|
|
await sut.getSegment(auth, assetId, sessionId, variantIndex, 'init.mp4', 7);
|
|
expect(mocks.websocket.serverSend).toHaveBeenCalledWith('HlsHeartbeat', {
|
|
sessionId,
|
|
variantIndex,
|
|
segmentIndex: 7,
|
|
});
|
|
});
|
|
|
|
it('prefers the initSegment hint over the lastRequested + 1 fallback', async () => {
|
|
await sut.getSegment(auth, assetId, sessionId, variantIndex, 'seg_5.m4s'); // fallback would be 6
|
|
mocks.websocket.serverSend.mockClear();
|
|
await sut.getSegment(auth, assetId, sessionId, variantIndex, 'init.mp4', 10);
|
|
expect(mocks.websocket.serverSend).toHaveBeenCalledWith('HlsHeartbeat', {
|
|
sessionId,
|
|
variantIndex,
|
|
segmentIndex: 10,
|
|
});
|
|
});
|
|
|
|
it('ignores the initSegment hint for media segment requests (the filename wins)', async () => {
|
|
await sut.getSegment(auth, assetId, sessionId, variantIndex, 'seg_5.m4s', 99);
|
|
expect(mocks.websocket.serverSend).toHaveBeenCalledWith('HlsHeartbeat', {
|
|
sessionId,
|
|
variantIndex,
|
|
segmentIndex: 5,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('endSession', () => {
|
|
it('emits HlsSessionEnd', async () => {
|
|
const auth = factory.auth();
|
|
const assetId = 'asset-1';
|
|
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId]));
|
|
await sut.endSession(auth, assetId, sessionId);
|
|
expect(mocks.websocket.serverSend).toHaveBeenCalledWith('HlsSessionEnd', { sessionId });
|
|
});
|
|
});
|
|
});
|