feat(05-jamtrack): implement server-side packaging flow

Implements the missing server-side packaging phase that exists in the
legacy system. JamTracks must be packaged/signed on the server before
they can be downloaded.

**Legacy System Flow (8 states):**
1. initial → 2. packaging → 3. downloading → 4. keying → 5. synchronized

**Previous Implementation (6 states - INCORRECT):**
1. idle → 2. checking → 3. downloading (skipped packaging!)

**New Implementation (7 states - CORRECT):**
1. idle → 2. checking → 3. packaging → 4. downloading → 5. keying → 6. synchronized

Changes:

1. REST API (rest.js):
   - Added enqueueMixdown(packageId) endpoint
   - POST /mixdowns/{packageId}/enqueue

2. Redux State (mediaSlice.js):
   - Added 'packaging' state to downloadState
   - Added packageId, packaging_steps, current_packaging_step, signing_state fields
   - Updated clearDownloadState to reset new fields

3. Download Logic (mediaSlice.js downloadJamTrack thunk):
   - Added packaging phase before download
   - Calls enqueueMixdown API
   - Waits for signing_state === 'SIGNED' (polls Redux state every 500ms)
   - 60 second timeout with clear error messages
   - Only proceeds to download after package is signed

4. WebSocket Handler (useSessionWebSocket.js):
   - Added SUBSCRIBE_NOTIFICATION handler
   - Updates packaging progress: signing_state, packaging_steps, current_packaging_step
   - Handles packaging errors: ERROR, SIGNING_TIMEOUT, QUEUED_TIMEOUT, QUIET_TIMEOUT
   - Logs packaging progress for debugging

5. UI (JKSessionJamTrackPlayer.js):
   - Added packaging state display
   - Shows "Your JamTrack is currently being created in the JamKazam server"
   - Displays signing_state and step progress (X of Y)
   - Shows spinner during packaging
   - Matches legacy system UX

This fixes the critical issue where JamTracks were attempted to be
downloaded without server-side packaging, which would always fail.

The flow now matches the legacy download_jamtrack.js.coffee behavior
documented in .planning/codebase/JAMTRACK_LEGACY.md lines 57-85.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Nuwan 2026-01-15 14:49:15 +05:30
parent 5fd80ae007
commit 00e2f9cabd
5 changed files with 152 additions and 10 deletions

View File

@ -43,7 +43,8 @@
"Skill(gsd:execute-plan)",
"Skill(gsd:plan-phase)",
"Skill(gsd:progress)",
"Bash(git rev-parse:*)"
"Bash(git rev-parse:*)",
"Bash(node --check:*)"
]
}
}

View File

@ -487,11 +487,32 @@ const JKSessionJamTrackPlayer = ({
<div style={{ background: '#f0f8ff', padding: '15px', marginBottom: '10px', border: '1px solid #cce7ff' }}>
<h4>
{downloadState.state === 'checking' && 'Checking sync status...'}
{downloadState.state === 'packaging' && 'Your JamTrack is currently being created in the JamKazam server'}
{downloadState.state === 'downloading' && 'Downloading JamTrack...'}
{downloadState.state === 'keying' && 'Requesting decryption keys...'}
{downloadState.state === 'error' && 'Download Failed'}
</h4>
{downloadState.state === 'packaging' && (
<div>
<p style={{ marginTop: '10px', fontSize: '14px' }}>
{downloadState.signing_state === 'SIGNED' && 'Package ready, starting download...'}
{downloadState.signing_state !== 'SIGNED' && downloadState.signing_state && `Status: ${downloadState.signing_state}`}
{!downloadState.signing_state && 'Preparing your JamTrack...'}
</p>
{downloadState.packaging_steps > 0 && (
<p style={{ fontSize: '14px', marginTop: '5px' }}>
Step {downloadState.current_packaging_step} of {downloadState.packaging_steps}
</p>
)}
<div style={{ marginTop: '10px' }}>
<div className="spinner-border spinner-border-sm" role="status">
<span className="sr-only">Packaging...</span>
</div>
</div>
</div>
)}
{downloadState.state === 'downloading' && (
<div>
<progress

View File

@ -558,6 +558,16 @@ export const getJamTrackBySlug = options => {
});
}
export const enqueueMixdown = (packageId) => {
return new Promise((resolve, reject) => {
apiFetch(`/mixdowns/${packageId}/enqueue`, {
method: 'POST'
})
.then(response => resolve(response))
.catch(error => reject(error));
});
};
export const addJamtrackToShoppingCart = (options = {}) => {
return new Promise((resolve, reject) => {
apiFetch(`/shopping_carts/add_jamtrack`, {

View File

@ -161,6 +161,38 @@ export const useSessionWebSocket = (sessionId) => {
}
},
// Phase 5 Fix: Handle packaging notifications from server
// Listens for mixdown package creation/signing progress
SUBSCRIBE_NOTIFICATION: (data) => {
console.log('[JamTrack] Packaging notification received:', data);
// Update packaging progress in Redux
// data.body contains: signing_state, packaging_steps, current_packaging_step
if (data.type === 'mixdown' && data.body) {
dispatch(setDownloadState({
signing_state: data.body.signing_state,
packaging_steps: data.body.packaging_steps || 0,
current_packaging_step: data.body.current_packaging_step || 0
}));
console.log(`[JamTrack] Packaging progress: ${data.body.signing_state}, step ${data.body.current_packaging_step}/${data.body.packaging_steps}`);
// If packaging failed, set error state
if (data.body.signing_state === 'ERROR' ||
data.body.signing_state === 'SIGNING_TIMEOUT' ||
data.body.signing_state === 'QUEUED_TIMEOUT' ||
data.body.signing_state === 'QUIET_TIMEOUT') {
dispatch(setDownloadState({
state: 'error',
error: {
type: 'download',
message: `Packaging failed: ${data.body.signing_state}`
}
}));
}
}
},
// Connection events
connectionStatusChanged: (data) => {
console.log('Connection status changed:', data);

View File

@ -1,4 +1,5 @@
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import { enqueueMixdown } from '../../helpers/rest';
// Async thunks for media actions
export const openBackingTrack = createAsyncThunk(
@ -49,15 +50,8 @@ export const loadJamTrack = createAsyncThunk(
export const downloadJamTrack = createAsyncThunk(
'media/downloadJamTrack',
async ({ jamTrack, mixdownId, fqId, jamClient }, { dispatch, rejectWithValue }) => {
async ({ jamTrack, mixdownId, fqId, jamClient }, { dispatch, rejectWithValue, getState }) => {
try {
dispatch(setDownloadState({
jamTrackId: jamTrack.id,
mixdownId,
fqId,
state: 'downloading',
progress: 0
}));
// Get client sample rate for package selection (pickMyPackage logic)
const rawSampleRate = await jamClient.GetSampleRate();
@ -126,6 +120,81 @@ export const downloadJamTrack = createAsyncThunk(
const packageId = compatiblePackage.id;
// ========== PACKAGING PHASE ==========
// Before downloading, package must be created/signed on server
// This matches legacy download_jamtrack.js.coffee behavior
dispatch(setDownloadState({
jamTrackId: jamTrack.id,
mixdownId,
fqId,
packageId,
state: 'packaging',
progress: 0,
packaging_steps: 0,
current_packaging_step: 0,
signing_state: null
}));
// Enqueue mixdown for server-side packaging
console.log(`[JamTrack] Enqueueing package ${packageId} for packaging`);
try {
await enqueueMixdown(packageId);
} catch (err) {
console.error('[JamTrack] Failed to enqueue mixdown:', err);
throw new Error(`Failed to start packaging: ${err.message}`);
}
// Wait for packaging to complete (signing_state === 'SIGNED')
// WebSocket handler will update downloadState as packaging progresses
console.log('[JamTrack] Waiting for package to be signed by server...');
const waitForPackaging = () => {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('Packaging timeout after 60 seconds'));
}, 60000); // 60 second timeout
const checkInterval = setInterval(() => {
const state = getState();
const { signing_state, state: downloadStateValue } = state.media.downloadState;
// Check for success
if (signing_state === 'SIGNED') {
clearTimeout(timeout);
clearInterval(checkInterval);
console.log('[JamTrack] Package signed successfully, proceeding to download');
resolve();
}
// Check for errors
if (signing_state === 'ERROR' || signing_state === 'SIGNING_TIMEOUT' ||
signing_state === 'QUEUED_TIMEOUT' || signing_state === 'QUIET_TIMEOUT') {
clearTimeout(timeout);
clearInterval(checkInterval);
reject(new Error(`Packaging failed with state: ${signing_state}`));
}
// Check if download state shows error
if (downloadStateValue === 'error') {
clearTimeout(timeout);
clearInterval(checkInterval);
reject(new Error('Packaging failed'));
}
}, 500); // Check every 500ms
});
};
await waitForPackaging();
// ========== DOWNLOAD PHASE ==========
// Package is now signed and ready to download
dispatch(setDownloadState({
state: 'downloading',
progress: 0
}));
// Set up global callbacks for native client
window.jamTrackDownloadProgress = (percent) => {
dispatch(setDownloadState({ progress: percent }));
@ -266,10 +335,15 @@ const initialState = {
jamTrackId: null,
mixdownId: null,
fqId: null,
state: 'idle', // 'idle' | 'checking' | 'downloading' | 'keying' | 'synchronized' | 'error'
packageId: null, // Package being packaged/downloaded
state: 'idle', // 'idle' | 'checking' | 'packaging' | 'downloading' | 'keying' | 'synchronized' | 'error'
progress: 0,
currentStep: 0,
totalSteps: 0,
// Packaging-specific fields (from legacy JamTrackStore)
packaging_steps: 0, // Total packaging steps
current_packaging_step: 0, // Current step in packaging
signing_state: null, // 'SIGNING_TIMEOUT' | 'QUEUED_TIMEOUT' | 'QUIET_TIMEOUT' | 'ERROR' | 'SIGNED' | null
error: null
},
@ -331,10 +405,14 @@ export const mediaSlice = createSlice({
jamTrackId: null,
mixdownId: null,
fqId: null,
packageId: null,
state: 'idle',
progress: 0,
currentStep: 0,
totalSteps: 0,
packaging_steps: 0,
current_packaging_step: 0,
signing_state: null,
error: null
};
},