/**
* @module server_access
* @description Mostly mirrors functions found in the {@link Server} class, but keeps the actual request logic
* out of that class.
*/
/**
* Authenticated fetch wrapper. Injects the auth token header if present
* and throws an Error with the server's error message on non-2xx status.
* @param {string} baseUrl - base URL of the server (e.g. "http://localhost:5000")
* @param {string} path - request path relative to baseUrl (e.g. "/api/projects")
* @param {RequestInit} [options] - fetch options (method, headers, body, etc.)
* @param {string} token - auth token to include in the X-Auth-Token header
* @returns {Promise<Response>}
*/
/**
* XHR-based request with upload progress support. Returns parsed JSON.
* @param {string} url - Full URL to request
* @param {string} method - HTTP method
* @param {FormData} body - Request body
* @param {string|null} token - Auth token
* @param {function|null} onProgress - Called with fraction (0–1) as upload progresses
* @param {AbortSignal} [signal] - Optional abort signal.
* @returns {Promise<object>}
*/
function _xhrRequest(url, method, body, token, onProgress, signal) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open(method, url);
xhr.withCredentials = true;
if (token) xhr.setRequestHeader('X-Auth-Token', token);
if (onProgress) {
xhr.upload.onprogress = (e) => { if (e.lengthComputable) onProgress(e.loaded / e.total); };
}
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
try { resolve(JSON.parse(xhr.responseText)); } catch { resolve({}); }
} else {
let body = {};
try { body = JSON.parse(xhr.responseText); } catch {}
reject(new Error(body.error || `HTTP ${xhr.status}`));
}
};
xhr.onerror = () => reject(new Error('Network error'));
xhr.onabort = () => reject(new DOMException('Upload aborted', 'AbortError'));
if (signal) {
signal.addEventListener('abort', () => xhr.abort(), { once: true });
}
xhr.send(body);
});
}
/**
* Authenticated fetch wrapper. Attaches the auth token header and throws on
* non-2xx responses, parsing any JSON error body for the message.
* @param {string} baseUrl - server origin (e.g. "http://localhost:5000")
* @param {string} path - request path (e.g. "/api/projects")
* @param {object} [options] - fetch init options (method, body, headers, etc.)
* @param {string} [token] - optional auth token; added as X-Auth-Token header
* @returns {Promise<Response>} resolved fetch Response
*/
export async function _fetch(baseUrl, path, options = {}, token) {
const headers = { ...options.headers };
if (token) {
headers['X-Auth-Token'] = token;
}
const resp = await fetch(baseUrl + path, {
credentials: 'include', // send cookies alongside the token header
...options,
headers,
});
if (!resp.ok) {
// Try to parse a JSON error body; fall back to the HTTP status code
const body = await resp.json().catch(() => ({}));
throw new Error(body.error || `HTTP ${resp.status}`);
}
return resp;
}
// ----- USER ME ----- //
/**
* Fetches the current user's profile from the backend.
* Used to verify backend access after Firebase sign-in.
* @param {string} baseUrl - base URL of the server
* @param {string} token - Firebase ID token
* @returns {Promise<object>} the user profile object
*/
export async function _getUserMe(baseUrl, token) {
const resp = await _fetch(baseUrl, '/api/users/me', { method: 'GET' }, token);
return await resp.json();
}
/**
* Fetch the list of all server-side projects.
* @param {string} baseUrl - the base url of the server
* @param {string} token - the access token of the server
* @returns {Promise<object[]>} array of project metadata objects
*/
export async function _getProjectList(baseUrl, token) {
const projectList = await _fetch(baseUrl, '/api/projects', { method: 'GET' }, token);
return await projectList.json();
}
/**
* Fetch projects shared with the current user.
* @param {string} baseUrl - Base URL of the server.
* @param {string} token - Auth token for the current session.
* @returns {Promise<object[]>} Array of shared project metadata objects.
*/
export async function _getSharedProjects(baseUrl, token) {
const resp = await _fetch(baseUrl, '/api/projects/shared', { method: 'GET' }, token);
return await resp.json();
}
/**
* Fetch folders shared with the current user.
* @param {string} baseUrl - Base URL of the server.
* @param {string} token - Auth token for the current session.
* @returns {Promise<object[]>} Array of shared folder metadata objects.
*/
export async function _getSharedFolders(baseUrl, token) {
const resp = await _fetch(baseUrl, '/api/folders/shared', { method: 'GET' }, token);
return await resp.json();
}
/**
* Fetch a single project by id.
* @param {string} baseUrl - base URL of the server
* @param {string} token - auth token for the current session
* @param {string} projectId - the project's unique identifier
* @returns {Promise<object>} the project metadata object
*/
export async function _getProject(baseUrl, token, projectId) {
const project = await _fetch(baseUrl, `/api/projects/${projectId}`, { method: 'GET' }, token);
return await project.json();
}
/**
* Duplicate a project on the server and return the new project object.
* @param {string} baseUrl - base URL of the server
* @param {string} token - auth token for the current session
* @param {string} projectId - the project's unique identifier
* @param {string} [folderPath] - destination folder path for the duplicate
* @returns {Promise<object>} the newly created duplicate project object
*/
export async function _duplicateProject(baseUrl, token, projectId, folderPath) {
const response = await _fetch(baseUrl, `/api/projects/${projectId}/duplicate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ folder_path: folderPath ?? '' }),
}, token);
return await response.json();
}
/**
* Permanently delete a project from the server.
* @param {string} baseUrl - base URL of the server
* @param {string} token - auth token for the current session
* @param {string} projectId - the project's unique identifier
* @returns {Promise<object>} server response object
*/
export async function _deleteProject(baseUrl, token, projectId) {
const response = await _fetch(baseUrl, `/api/projects/${projectId}`, { method: 'DELETE' }, token);
return await response.json();
}
/**
* Fetch storage size info for a project before/after compression.
* @param {string} baseUrl - base URL of the server
* @param {string} token - auth token for the current session
* @param {string} projectId - the project's unique identifier
* @returns {Promise<object>} { can_compress, before_bytes?, after_bytes?, audio_format?, reason? }
*/
export async function _getCompressInfo(baseUrl, token, projectId) {
const response = await _fetch(baseUrl, `/api/projects/${projectId}/compress_info`, {}, token);
return await response.json();
}
/**
* Compress a project by deleting the original non-MP3 audio file.
* @param {string} baseUrl - base URL of the server
* @param {string} token - auth token for the current session
* @param {string} projectId - the project's unique identifier
* @returns {Promise<object>} { ok, audio_format }
*/
export async function _compressProject(baseUrl, token, projectId) {
const response = await _fetch(baseUrl, `/api/projects/${projectId}/compress`, { method: 'POST' }, token);
return await response.json();
}
/**
* Upload a brand-new project (first push from local).
* @param {string} baseUrl - base URL of the server
* @param {string} token - auth token for the current session
* @param {object} metadata - general project metadata and speakers; no waveform details
* @param {object|null} waveformData - { sampleRate, duration, filename, peaks }
* @param {File} audioFile - the audio file to upload
* @param {File|null} transcriptFile - the transcript CSV file, or null if none
* @param {Object.<string, File>} sampleFiles - speaker sample files keyed by speaker id
* @returns {Promise<object>} the newly created project object
*/
export async function _createProject(baseUrl, token, metadata,
{waveformData = null, audioFile = null, transcriptFile = null, sampleFiles = null, onProgress = null, signal = null} = {}) {
const data = new FormData();
data.append('metadata', JSON.stringify(metadata));
// Send as a file Blob so it goes to request.files, bypassing Werkzeug's
// in-memory form-field size limit (default 500 kB) for large peaks arrays.
if (waveformData) {
data.append('waveform', new Blob([JSON.stringify(waveformData)], { type: 'application/json' }), 'waveform.json');
}
// append the audio file
if (audioFile) {
data.append('audio', audioFile);
}
// append the transcript if it exists
if (transcriptFile) {
data.append('transcript', transcriptFile);
}
if (sampleFiles) {
// Append each speaker sample with a namespaced key the Flask API can parse
for (const [sid, f] of Object.entries(sampleFiles || {})) {
data.append(`samples[${sid}]`, f);
}
}
return await _xhrRequest(baseUrl + '/api/projects', 'POST', data, token, onProgress, signal);
}
/**
* Push updated data for an existing server project.
* Audio is immutable once uploaded — only metadata, speakers, and transcript can be updated.
* @param {string} baseUrl - base URL of the server
* @param {string} token - auth token for the current session
* @param {string} projectId - the project's unique identifier
* @param {string} id - the project id (included in metadata)
* @param {object|null} metadata - { name, created, modified } (no speakers)
* @param {object|null} speakersData - full speakers dict if speakers section is dirty
* @param {File|null} transcriptFile - the updated transcript file, or null to leave unchanged
* @param {Object.<string, File>} sampleFiles - updated speaker sample files keyed by speaker id
* @returns {Promise<object>} the updated project object
*/
// TODO: (issue-11) The audio is currently immutable, but it should not be. However, there should be some indication of potential transcript mismatch if so
export async function _updateProject(baseUrl, token, projectId, metadata,
{speakersData = null, audioFile = null, transcriptFile = null, sampleFiles = null, onProgress = null, signal = null} = {}) {
const data = new FormData();
// append the metadata
data.append('metadata', JSON.stringify(metadata));
// if passed, append the speaker data
if (speakersData) {
data.append('speakers', JSON.stringify(speakersData));
}
// if passed, append the audio file
if (audioFile) {
data.append('audio', audioFile);
}
// if passed, append the transcript file
if (transcriptFile) {
data.append('transcript', transcriptFile);
}
// if passed, append the speaker samples
if (sampleFiles) {
for (const [sid, f] of Object.entries(sampleFiles || {})) {
data.append(`samples[${sid}]`, f);
}
}
return await _xhrRequest(baseUrl + `/api/projects/${projectId}`, 'PUT', data, token, onProgress, signal);
}
/**
* Fetches the waveform metadata (sampleRate, duration, filename, peaks) for a project.
* @param {string} baseUrl - base URL of the server
* @param {string} token - auth token for the current session
* @param {string} projectId - the project's unique identifier
* @returns {Promise<object>} waveform metadata object
*/
export async function _getWaveform(baseUrl, token, projectId) {
const response = await _fetch(baseUrl, `/api/projects/${projectId}/waveform`, { method: 'GET'}, token);
return await response.json();
}
/**
* Fetches the speakers dictionary for a project.
* @param {string} baseUrl - base URL of the server
* @param {string} token - auth token for the current session
* @param {string} projectId - the project's unique identifier
* @returns {Promise<object>} dictionary of speaker objects keyed by speaker id
*/
export async function _getSpeakers(baseUrl, token, projectId) {
const response = await _fetch(baseUrl, `/api/projects/${projectId}/speakers`, { method: 'GET' }, token);
return await response.json();
}
/**
* Fetches a speaker's voice sample as an ArrayBuffer.
* @param {string} baseUrl - base URL of the server
* @param {string} token - auth token for the current session
* @param {string} projectId - the project's unique identifier
* @param {string} speakerId - the speaker's unique identifier
* @returns {Promise<ArrayBuffer>} raw audio data for the speaker's voice sample
*/
export async function _getSpeakerSample(baseUrl, token, projectId, speakerId) {
const response = await _fetch(baseUrl, `/api/projects/${projectId}/samples/${speakerId}`, { method: 'GET' }, token);
return await response.arrayBuffer();
}
/**
* Fetches the transcript CSV text for a project.
* @param {string} baseUrl - base URL of the server
* @param {string} token - auth token for the current session
* @param {string} projectId - the project's unique identifier
* @returns {Promise<{contentType: string, text: string}>} the content type and raw body text
*/
export async function _getTranscript(baseUrl, token, projectId) {
const response = await _fetch(baseUrl, `/api/projects/${projectId}/transcript`, { method: 'GET' }, token);
const contentType = response.headers.get('Content-Type') || '';
const text = await response.text();
return { contentType, text };
}
// ----- FOLDER API ----- //
/**
* Encode a folder path for use in a URL, preserving slashes between segments.
* @param {string} folderPath - relative folder path ('' for root)
* @returns {string} URL-encoded path with slashes preserved.
*/
function _encodeFolderPath(folderPath) {
return folderPath.split('/').map(s => encodeURIComponent(s)).join('/');
}
/**
* Lists the immediate contents of a server folder.
* @param {string} baseUrl - base URL of the server
* @param {string} token - auth token for the current session
* @param {string} folderPath - relative folder path ('' for root)
* @returns {Promise<{folders: object[], projects: object[]}>}
*/
export async function _listDirectory(baseUrl, token, folderPath) {
const qs = folderPath ? `?path=${encodeURIComponent(folderPath)}` : '';
const response = await _fetch(baseUrl, `/api/folders${qs}`, { method: 'GET' }, token);
return await response.json();
}
/**
* Creates a new folder on the server.
* @param {string} baseUrl - base URL of the server
* @param {string} token - auth token for the current session
* @param {string} parentPath - path of the parent folder ('' for root)
* @param {string} name - name of the new folder
* @returns {Promise<{name: string, path: string}>}
*/
export async function _createFolder(baseUrl, token, parentPath, name) {
const response = await _fetch(baseUrl, '/api/folders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ parent_path: parentPath, name }),
}, token);
return await response.json();
}
/**
* Renames a folder on the server.
* @param {string} baseUrl - base URL of the server
* @param {string} token - auth token for the current session
* @param {string} folderPath - current path of the folder to rename
* @param {string} newName - new name for the folder
* @returns {Promise<{name: string, path: string}>}
*/
export async function _renameFolder(baseUrl, token, folderPath, newName) {
const response = await _fetch(baseUrl, `/api/folders/${_encodeFolderPath(folderPath)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newName }),
}, token);
return await response.json();
}
/**
* Deletes a folder on the server.
* @param {string} baseUrl - base URL of the server
* @param {string} token - auth token for the current session
* @param {string} folderPath - path of the folder to delete
* @param {boolean} merge - if true, move contents to parent before deleting
* @returns {Promise<{ok: boolean}>}
*/
export async function _deleteFolder(baseUrl, token, folderPath, merge) {
const qs = merge ? '?merge=true' : '';
const response = await _fetch(baseUrl, `/api/folders/${_encodeFolderPath(folderPath)}${qs}`, {
method: 'DELETE',
}, token);
return await response.json();
}
/**
* Moves a project to a different folder on the server.
* @param {string} baseUrl - base URL of the server
* @param {string} token - auth token for the current session
* @param {string} projectId - the project's unique identifier
* @param {string} targetFolderPath - destination folder path ('' for root)
* @returns {Promise<object>} updated project summary
*/
export async function _moveProjectToFolder(baseUrl, token, projectId, targetFolderPath) {
const response = await _fetch(baseUrl, `/api/projects/${projectId}/folder`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ folder_path: targetFolderPath }),
}, token);
return await response.json();
}
/**
* Gets the count of projects and subfolders within a folder.
* @param {string} baseUrl - base URL of the server
* @param {string} token - auth token for the current session
* @param {string} folderPath - path of the folder to count
* @returns {Promise<{projects: number, folders: number}>}
*/
export async function _getFolderCount(baseUrl, token, folderPath) {
const response = await _fetch(baseUrl, `/api/folders/${_encodeFolderPath(folderPath)}/count`, {
method: 'GET',
}, token);
return await response.json();
}
// ----- TRANSCRIPTION API ----- //
/**
* Checks whether the audio.mp3 for a project is ready for transcription.
* @param {string} baseUrl - base URL of the server
* @param {string} token - auth token for the current session
* @param {string} projectId - the project's unique identifier
* @returns {Promise<boolean>} true if audio.mp3 exists and is ready
*/
export async function _checkAudioReady(baseUrl, token, projectId) {
const response = await _fetch(baseUrl, `/api/projects/${projectId}/audio-ready`, { method: 'GET' }, token);
const data = await response.json();
return data.ready === true;
}
/**
* Deletes the transcript for a project.
* @param {string} baseUrl - base URL of the server
* @param {string} token - auth token for the current session
* @param {string} projectId - the project's unique identifier
* @returns {Promise<void>}
*/
export async function _deleteTranscript(baseUrl, token, projectId) {
await _fetch(baseUrl, `/api/projects/${projectId}/transcript`, { method: 'DELETE' }, token);
}
/**
* Deletes the audio file for a project.
* @param {string} baseUrl - base URL of the server
* @param {string} token - auth token for the current session
* @param {string} projectId - the project's unique identifier
* @returns {Promise<void>}
*/
export async function _deleteAudio(baseUrl, token, projectId) {
await _fetch(baseUrl, `/api/projects/${projectId}/audio`, { method: 'DELETE' }, token);
}
/**
* Saves waveform metadata (peaks, duration, sampleRate) back to the server.
* @param {string} baseUrl - base URL of the server
* @param {string} token - auth token for the current session
* @param {string} projectId - the project's unique identifier
* @param {{peaks: number[], duration: number, sampleRate: number}} waveformData - Waveform peaks and audio metadata.
* @returns {Promise<void>}
*/
export async function _saveWaveform(baseUrl, token, projectId, waveformData) {
const form = new FormData();
form.append('waveform', new Blob([JSON.stringify(waveformData)], { type: 'application/json' }), 'waveform.json');
await _fetch(baseUrl, `/api/projects/${projectId}/waveform`, {
method: 'PUT',
body: form,
}, token);
}
/**
* Fetch the annotations (hyperlinks, etc.) for a project.
* @param {string} baseUrl - base URL of the server
* @param {string} token - auth token for the current session
* @param {string} projectId - the project's unique identifier
* @returns {Promise<object>} annotations object with a 'hyperlinks' key
*/
export async function _getAnnotations(baseUrl, token, projectId) {
const resp = await _fetch(baseUrl, `/api/projects/${projectId}/annotations`, { method: 'GET' }, token);
return await resp.json();
}
/**
* Save annotations (hyperlinks, etc.) for a project.
* @param {string} baseUrl - base URL of the server
* @param {string} token - auth token for the current session
* @param {string} projectId - the project's unique identifier
* @param {object} data - annotations object with a 'hyperlinks' key
* @returns {Promise<void>}
*/
export async function _saveAnnotations(baseUrl, token, projectId, data) {
await _fetch(baseUrl, `/api/projects/${projectId}/annotations`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
}, token);
}
/**
* Starts transcription and streams SSE progress events via a callback.
* Resolves when the stream closes (after a 'result' or 'error' event).
* Each event object has a 'type' field: 'status', 'progress', 'result', or 'error'.
* @param {string} baseUrl - base URL of the server
* @param {string} token - auth token for the current session
* @param {string} projectId - the project's unique identifier
* @param {object} options - transcription options
* @param {string} [options.modelSize] - whisper model size (default 'medium')
* @param {number|null} [options.speakerCount] - number of speakers, or null for auto-detect
* @param {function} onEvent - called with each parsed SSE event object
* @param {AbortSignal} [signal] - optional abort signal
* @returns {Promise<void>}
*/
export async function _transcribeProject(baseUrl, token, projectId, options, onEvent, signal) {
const resp = await _fetch(baseUrl, `/api/projects/${projectId}/transcribe`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model_size: options.modelSize ?? 'medium',
pyannote_model: options.pyannoteModel ?? 'pyannote/speaker-diarization-3.1',
speaker_count: options.speakerCount ?? null,
include_voice_samples: options.includeVoiceSamples ?? false,
}),
signal,
}, token);
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop(); // keep any incomplete trailing line
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
onEvent(JSON.parse(line.slice(6)));
} catch { /* ignore malformed lines */ }
}
}
}
}
/**
* Retranscribes specific time-range segments and returns updated word-level timestamps.
* @param {string} baseUrl - Base URL of the server
* @param {string} token - Auth token
* @param {string} projectId - The project ID
* @param {Array<{start: number, end: number, idx: number}>} segments - Segments to retranscribe
* @param {string} [modelSize] - Whisper model size to use
* @returns {Promise<{results: Array<{idx: number, words: Array}>}>}
*/
export async function _retranscribeSegments(baseUrl, token, projectId, segments, modelSize = 'medium') {
const resp = await _fetch(baseUrl, `/api/projects/${projectId}/retranscribe_segments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ segments, model_size: modelSize }),
}, token);
return await resp.json();
}