import { _fetch,
_getProjectList, _getSharedProjects, _getProject, _createProject, _deleteProject, _duplicateProject, _updateProject,
_getWaveform, _getSpeakers, _getSpeakerSample, _getTranscript,
_listDirectory, _createFolder, _renameFolder, _deleteFolder, _moveProjectToFolder, _getFolderCount,
_checkAudioReady, _transcribeProject, _retranscribeSegments, _deleteTranscript, _deleteAudio, _getUserMe,
_getSharedFolders, _saveWaveform, _getAnnotations, _saveAnnotations,
_getCompressInfo, _compressProject,
} from "./utilities/server_access.js"
import { firebaseAuth, signOut } from "./firebase.js"
/**
* Manages the connection to the backend server, including Firebase-based
* authentication and all server-side data operations.
*/
export class Server {
/**
* @param {object} callbacks - callback functions for server connection events
* @param {function} callbacks.onStatusChanged - Fired whenever `serverStatus` changes.
* @param {function} callbacks.onConnect - Fired after a successful login.
* @param {function} callbacks.onDisconnect - Fired after a successful logout.
*/
constructor({ onStatusChanged, onConnect, onDisconnect }) {
this.isConnected = false; // true after successful login
this.baseUrl = null; // e.g. "http://localhost:5000"
this.serverStatus = "Disconnected";
/** Firebase user object — set when signed in via Firebase Auth.
* @type {object|null}
*/
this.firebaseUser = null;
/** Backend user profile returned by /api/users/me.
* @type {object|null}
*/
this.backendUser = null;
/** Cached Firebase ID token — refreshed on each getToken() call */
this._cachedToken = null;
this._onStatusChanged = onStatusChanged ?? (() => {})
this._onConnect = onConnect ?? (() => {})
this._onDisconnect = onDisconnect ?? (() => {})
}
// ── Auth ────────────────────────────────────────────────────────────────
/**
* Returns a fresh Firebase ID token (auto-refreshes if near expiry).
* @returns {Promise<string|null>}
*/
async getToken() {
if (!this.firebaseUser) return null;
this._cachedToken = await this.firebaseUser.getIdToken();
return this._cachedToken;
}
/**
* Connects to the server using a Firebase-authenticated user.
* Gets a fresh ID token, verifies backend access via /api/users/me,
* then fires onConnect.
* @param {string} serverUrl - Base URL of the server (trailing slash is stripped).
* @param {object} firebaseUser - Signed-in Firebase user.
* @returns {Promise<void>}
*/
async connectWithFirebaseUser(serverUrl, firebaseUser) {
const serverUrlFixed = serverUrl.replace(/\/$/, '');
let token;
try {
token = await firebaseUser.getIdToken();
} catch (e) {
this.serverStatus = e.message || 'Failed to get auth token';
this._onStatusChanged();
return;
}
// Verify backend access
let backendUser;
try {
backendUser = await _getUserMe(serverUrlFixed, token);
} catch (e) {
this.serverStatus = e.message || 'Server rejected the login';
this._onStatusChanged();
throw e;
}
this.firebaseUser = firebaseUser;
this._cachedToken = token;
this.backendUser = backendUser;
this.isConnected = true;
this.baseUrl = serverUrlFixed;
this.serverStatus = 'Connected';
this._onConnect();
this._onStatusChanged();
}
/**
* Signs out of Firebase and disconnects from the server.
* @returns {Promise<void>}
*/
/**
* Connects to the local embedded server without Firebase authentication.
* Used in LOCAL_MODE where the backend accepts requests with no token.
* @returns {Promise<void>}
*/
async connectLocal() {
const baseUrl = window.location.origin;
let backendUser;
const delays = [500, 1000, 2000, 4000, 8000];
for (let attempt = 0; attempt <= delays.length; attempt++) {
try {
backendUser = await _getUserMe(baseUrl, null);
break;
} catch (e) {
if (attempt === delays.length) {
this.serverStatus = e.message || 'Failed to connect to local server';
this._onStatusChanged();
return;
}
this.serverStatus = 'Reconnecting…';
this._onStatusChanged();
await new Promise(r => setTimeout(r, delays[attempt]));
}
}
this.firebaseUser = null;
this._cachedToken = null;
this.backendUser = backendUser;
this.isConnected = true;
this.baseUrl = baseUrl;
this.serverStatus = 'Connected';
this._onConnect();
this._onStatusChanged();
}
/** Signs out of Firebase and resets all connection state. */
async disconnectFromServer() {
if (firebaseAuth) {
try {
await signOut(firebaseAuth);
} catch (e) {
console.error('Firebase sign-out error:', e);
}
}
this.firebaseUser = null;
this._cachedToken = null;
this.backendUser = null;
this.isConnected = false;
this.baseUrl = null;
this.serverStatus = 'Disconnected';
this._onDisconnect();
this._onStatusChanged();
}
// ----- SERVER PROJECT LIFECYCLE ----- //
/**
* Retrieves the list of all projects from the server.
* @returns {Promise<object[]>} Array of project metadata objects.
*/
async getProjectList() {
return await _getProjectList(this.baseUrl, await this.getToken());
}
/**
* Retrieves projects shared with the current user.
* @returns {Promise<object[]>} Array of shared project metadata objects.
*/
async getSharedProjects() {
return await _getSharedProjects(this.baseUrl, await this.getToken());
}
/**
* Retrieves folders shared with the current user.
* @returns {Promise<object[]>} Array of shared folder metadata objects.
*/
async getSharedFolders() {
return await _getSharedFolders(this.baseUrl, await this.getToken());
}
/**
* Retrieves a single project by ID.
* @param {string} projectId - The project's unique identifier.
* @returns {Promise<object>} The project object.
*/
async getProject(projectId) {
return await _getProject(this.baseUrl, await this.getToken(), projectId);
}
/**
* Deletes a project by ID.
* @param {string} projectId - The project's unique identifier.
* @returns {Promise<boolean>} True if deletion was successful.
*/
async deleteProject(projectId) {
return await _deleteProject(this.baseUrl, await this.getToken(), projectId)
}
/**
* Returns storage size info for a project before and after compression.
* @param {string} projectId - The project's unique identifier.
* @returns {Promise<object>} { can_compress, before_bytes?, after_bytes?, audio_format?, reason? }
*/
async getCompressInfo(projectId) {
return await _getCompressInfo(this.baseUrl, await this.getToken(), projectId);
}
/**
* Compresses a project by deleting its original non-MP3 audio file.
* @param {string} projectId - The project's unique identifier.
* @returns {Promise<object>} { ok, audio_format }
*/
async compressProject(projectId) {
return await _compressProject(this.baseUrl, await this.getToken(), projectId);
}
/**
* Duplicates a project on the server and returns the new project object.
* @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.
*/
async duplicateProject(projectId, folderPath) {
return await _duplicateProject(this.baseUrl, await this.getToken(), projectId, folderPath);
}
/**
* Creates a new project on the server.
* @param {object} metadata - Project metadata (name, description, etc.).
* @param {object} waveformData - Pre-computed waveform data.
* @param {File|null} audioFile - The audio file to upload, or null to create an empty project.
* @param {File} transcriptFile - The transcript CSV file to upload.
* @param {File[]} sampleFiles - Speaker voice sample files to upload.
* @param {function(number):void} [onProgress] - upload progress callback, fraction 0–1.
* @param {AbortSignal} [signal] - Optional signal to abort the request.
* @returns {Promise<object>} The newly created project object.
*/
async createProject(metadata, waveformData, audioFile, transcriptFile, sampleFiles, onProgress, signal) {
const data = {waveformData, audioFile, transcriptFile, sampleFiles, onProgress, signal};
return await _createProject(this.baseUrl, await this.getToken(), metadata, data);
}
/**
* Updates an existing project on the server.
* @param {string} projectId - The project's unique identifier.
* @param {object} metadata - Updated project metadata (name, created, modified).
* @param {object|null} speakersData - Full speakers dict if speakers section is dirty, or null.
* @param {File|null} audioFile - Replacement audio file, or null to leave unchanged.
* @param {File|null} transcriptFile - Replacement transcript file, or null to leave unchanged.
* @param {Object.<string, File>} sampleFiles - Replacement sample files keyed by speaker id.
* @param {function(number):void} [onProgress] - upload progress callback, fraction 0–1.
* @param {AbortSignal} [signal] - Optional signal to abort the request.
* @returns {Promise<object>} The updated project object.
*/
async updateProject(projectId, metadata, speakersData, audioFile, transcriptFile, sampleFiles, onProgress, signal) {
const data = {speakersData, audioFile, transcriptFile, sampleFiles, onProgress, signal};
return await _updateProject(this.baseUrl, await this.getToken(), projectId, metadata, data);
}
// ----- DATA ACCESS ----- //
/**
* Retrieves the waveform data for a project.
* @param {string} projectId - The project's unique identifier.
* @returns {Promise<object>} The waveform data object.
*/
async getWaveform(projectId) {
return await _getWaveform(this.baseUrl, await this.getToken(), projectId);
}
/**
* Saves waveform metadata (peaks, duration, sampleRate) to the server.
* @param {string} projectId - The project's unique identifier.
* @param {{peaks: number[], duration: number, sampleRate: number}} waveformData - Waveform peaks and audio metadata.
* @returns {Promise<void>}
*/
async saveWaveform(projectId, waveformData) {
await _saveWaveform(this.baseUrl, await this.getToken(), projectId, waveformData);
}
/**
* Retrieves the speakers dictionary for a project.
* @param {string} projectId - The project's unique identifier.
* @returns {Promise<object>} Dictionary of speaker objects keyed by speaker id.
*/
async getSpeakers(projectId) {
return await _getSpeakers(this.baseUrl, await this.getToken(), projectId);
}
/**
* Retrieves a speaker's voice sample as an ArrayBuffer.
* @param {string} projectId - The project's unique identifier.
* @param {string} speakerId - The speaker's unique identifier.
* @returns {Promise<ArrayBuffer>} The raw audio data.
*/
async getSpeakerSample(projectId, speakerId) {
return await _getSpeakerSample(this.baseUrl, await this.getToken(), projectId, speakerId);
}
/**
* Retrieves the transcript for a project.
* @param {string} projectId - The project's unique identifier.
* @returns {Promise<{contentType: string, text: string}>} The content type and raw text body.
*/
async getTranscript(projectId) {
return await _getTranscript(this.baseUrl, await this.getToken(), projectId);
}
/**
* Retrieves the annotations (hyperlinks, etc.) for a project.
* @param {string} projectId - The project's unique identifier.
* @returns {Promise<object>} Annotations object with a 'hyperlinks' key.
*/
async getAnnotations(projectId) {
return await _getAnnotations(this.baseUrl, await this.getToken(), projectId);
}
/**
* Saves annotations (hyperlinks, etc.) for a project.
* @param {string} projectId - The project's unique identifier.
* @param {object} data - Annotations object with a 'hyperlinks' key.
* @returns {Promise<void>}
*/
async saveAnnotations(projectId, data) {
await _saveAnnotations(this.baseUrl, await this.getToken(), projectId, data);
}
// ----- FOLDER OPERATIONS ----- //
/**
* Lists the immediate contents of a server folder.
* @param {string} folderPath - relative folder path ('' for root)
* @returns {Promise<{folders: object[], projects: object[]}>}
*/
async listDirectory(folderPath) {
return await _listDirectory(this.baseUrl, await this.getToken(), folderPath);
}
/**
* Creates a new folder on the server.
* @param {string} parentPath - parent folder path ('' for root)
* @param {string} name - name for the new folder
* @returns {Promise<{name: string, path: string}>}
*/
async createFolder(parentPath, name) {
return await _createFolder(this.baseUrl, await this.getToken(), parentPath, name);
}
/**
* Renames a folder on the server.
* @param {string} folderPath - current path of the folder
* @param {string} newName - new name
* @returns {Promise<{name: string, path: string}>}
*/
async renameFolder(folderPath, newName) {
return await _renameFolder(this.baseUrl, await this.getToken(), folderPath, newName);
}
/**
* Deletes a folder on the server.
* @param {string} folderPath - folder to delete
* @param {boolean} merge - if true, move contents to parent first
* @returns {Promise<{ok: boolean}>}
*/
async deleteFolder(folderPath, merge) {
return await _deleteFolder(this.baseUrl, await this.getToken(), folderPath, merge);
}
/**
* Moves a project to a different folder.
* @param {string} projectId - the project's unique identifier
* @param {string} targetFolderPath - destination folder path ('' for root)
* @returns {Promise<object>} updated project summary
*/
async moveProjectToFolder(projectId, targetFolderPath) {
return await _moveProjectToFolder(this.baseUrl, await this.getToken(), projectId, targetFolderPath);
}
/**
* Gets the count of projects and subfolders within a folder.
* @param {string} folderPath - folder to inspect
* @returns {Promise<{projects: number, folders: number}>}
*/
async getFolderCount(folderPath) {
return await _getFolderCount(this.baseUrl, await this.getToken(), folderPath);
}
// ----- TRANSCRIPTION ----- //
/**
* Checks whether audio.mp3 is ready for a project.
* @param {string} projectId - The project's unique identifier.
* @returns {Promise<boolean>} True if audio.mp3 is available.
*/
async checkAudioReady(projectId) {
return await _checkAudioReady(this.baseUrl, await this.getToken(), projectId);
}
/**
* Starts transcription and streams progress events via a callback.
* @param {string} projectId - The project's unique identifier.
* @param {object} options - { modelSize, speakerCount }
* @param {function} onEvent - called with each SSE event object
* @param {AbortSignal} [signal] - optional abort signal
* @returns {Promise<void>}
*/
async transcribeProject(projectId, options, onEvent, signal) {
return await _transcribeProject(this.baseUrl, await this.getToken(), projectId, options, onEvent, signal);
}
/**
* Retranscribes specific time-range segments and returns updated word-level timestamps.
* @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}>}>}
*/
async retranscribeSegments(projectId, segments, modelSize = 'medium') {
return await _retranscribeSegments(this.baseUrl, await this.getToken(), projectId, segments, modelSize);
}
/**
* Deletes the transcript for a project.
* @param {string} projectId - The project's unique identifier.
* @returns {Promise<void>}
*/
async deleteTranscript(projectId) {
return await _deleteTranscript(this.baseUrl, await this.getToken(), projectId);
}
/**
* Deletes the audio file for a project.
* @param {string} projectId - The project's unique identifier.
* @returns {Promise<void>}
*/
async deleteAudio(projectId) {
return await _deleteAudio(this.baseUrl, await this.getToken(), projectId);
}
// ----- USER ----- //
/**
* Re-fetches /api/users/me and updates backendUser in place.
* Call this after operations that change storage usage so that
* subsequent file-size checks use current data.
* @returns {Promise<void>}
*/
async refreshUser() {
try {
this.backendUser = await _getUserMe(this.baseUrl, await this.getToken());
} catch (e) {
console.warn('Could not refresh user profile:', e);
}
}
// ----- URL HELPERS ----- //
/**
* Returns a URL that streams the project's audio.
* Uses the most recently cached token for <audio src> compatibility.
* @param {string} id - The project's unique identifier.
* @returns {string} The audio stream URL.
*/
audioUrl(id) {
return `${this.baseUrl}/api/projects/${id}/audio?token=${this._cachedToken}`;
}
/**
* Returns the URL to download the project's transcript CSV.
* @param {string} id - The project's unique identifier.
* @returns {string} The transcript download URL.
*/
transcriptUrl(id) {
return `${this.baseUrl}/api/projects/${id}/transcript`;
}
/**
* Returns a URL for a specific speaker's voice sample file.
* Uses the most recently cached token for direct link compatibility.
* @param {string} id - The project's unique identifier.
* @param {string} spkId - The speaker's unique identifier.
* @returns {string} The sample file URL.
*/
sampleUrl(id, spkId) {
return `${this.baseUrl}/api/projects/${id}/samples/${spkId}?token=${this._cachedToken}`;
}
}