Initial LisgloSIPS V2 implementation

This commit is contained in:
hectorzhao
2026-06-22 10:56:38 +08:00
commit 5fa1bd35e9
303 changed files with 35644 additions and 0 deletions
+115
View File
@@ -0,0 +1,115 @@
import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import {
callIdFromStorageKey,
normalizeRemoteRelativePath,
type PulledRecordingFile,
type RecordingStore,
RecordingTransferService,
type RemoteRecordingClient,
type RemoteRecordingFile,
storageKeyFromReadyPath
} from './transfer.js';
class MemoryRemote implements RemoteRecordingClient {
readonly deleted: string[] = [];
constructor(private readonly files: Map<string, Buffer>) {}
async listReadyFiles(): Promise<RemoteRecordingFile[]> {
return Array.from(this.files.entries()).map(([relativePath, content]) => ({
relativePath,
bytes: content.length
}));
}
async pullFile(file: RemoteRecordingFile, localPath: string): Promise<void> {
const content = this.files.get(file.relativePath);
if (!content) {
throw new Error('missing remote file');
}
await writeFile(localPath, content);
}
async deleteFile(file: RemoteRecordingFile): Promise<void> {
this.deleted.push(file.relativePath);
this.files.delete(file.relativePath);
}
}
class MemoryStore implements RecordingStore {
readonly ready: PulledRecordingFile[] = [];
async markReady(input: { storageKey: string; storagePath: string; sourceKey: string; sha256: string; bytes: bigint }): Promise<void> {
this.ready.push({
relativePath: input.sourceKey,
bytes: Number(input.bytes),
localPath: input.storagePath,
sha256: input.sha256
});
}
}
describe('recording transfer', () => {
it('normalizes ready paths safely', () => {
expect(normalizeRemoteRelativePath('/2026/06/21/call.wav.ready')).toBe('2026/06/21/call.wav.ready');
expect(storageKeyFromReadyPath('2026/06/21/call.wav.ready')).toBe('2026/06/21/call.wav');
expect(() => normalizeRemoteRelativePath('../call.wav.ready')).toThrow(/Unsafe/);
expect(() => storageKeyFromReadyPath('2026/06/21/call.wav')).toThrow(/ready/);
});
it('derives call IDs from RTPEngine recording names', () => {
expect(callIdFromStorageKey('2026/06/22/s28-1782091361841-kntoq1y9%40lisglosips-t-c68d7ba1de875f62-mix.wav')).toBe(
's28-1782091361841-kntoq1y9@lisglosips-t'
);
});
it('copies, hashes, marks ready, and deletes source only after verification', async () => {
const root = await mkdtemp(path.join(os.tmpdir(), 'lisglosips-rec-'));
const remote = new MemoryRemote(new Map([['2026/06/21/call.wav.ready', Buffer.from('audio-bytes')]]));
const store = new MemoryStore();
const service = new RecordingTransferService(remote, store, {
localRoot: root,
deleteSourceAfterCopy: true,
maxFilesPerScan: 10
});
try {
const result = await service.scanOnce();
expect(result).toEqual({ moved: 1, skipped: 0, failed: 0 });
expect(store.ready).toHaveLength(1);
expect(store.ready[0]?.sha256).toMatch(/^[a-f0-9]{64}$/);
expect(remote.deleted).toEqual(['2026/06/21/call.wav.ready']);
expect(await readFile(path.join(root, '2026/06/21/call.wav'), 'utf8')).toBe('audio-bytes');
} finally {
await rm(root, { recursive: true, force: true });
}
});
it('keeps source file when local size verification fails', async () => {
const root = await mkdtemp(path.join(os.tmpdir(), 'lisglosips-rec-'));
const remote = new MemoryRemote(new Map([['bad.wav.ready', Buffer.from('abc')]]));
const originalList = remote.listReadyFiles.bind(remote);
remote.listReadyFiles = async () => (await originalList()).map((file) => ({ ...file, bytes: file.bytes + 1 }));
const store = new MemoryStore();
const service = new RecordingTransferService(remote, store, {
localRoot: root,
deleteSourceAfterCopy: true,
maxFilesPerScan: 10
});
try {
const result = await service.scanOnce();
expect(result).toEqual({ moved: 0, skipped: 0, failed: 1 });
expect(store.ready).toHaveLength(0);
expect(remote.deleted).toHaveLength(0);
await expect(stat(path.join(root, 'bad.wav'))).rejects.toThrow();
} finally {
await rm(root, { recursive: true, force: true });
}
});
});