diff --git a/.claude/settings.local.json b/.claude/settings.local.json index d7ffe15..231d09c 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -52,7 +52,8 @@ "Bash(git merge:*)", "Bash(gh pr create:*)", "Bash(gh pr view:*)", - "Bash(gh pr edit:*)" + "Bash(gh pr edit:*)", + "Bash(git rebase:*)" ] } } diff --git a/TODO.md b/TODO.md index 75e22f4..4d19df3 100644 --- a/TODO.md +++ b/TODO.md @@ -30,20 +30,20 @@ Context: [CLAUDE.md](CLAUDE.md), [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING. - [ ] **Error alert UX** — Show structured error from API (cause + diagnosis: title, solutions); optional collapsible Details and FFmpeg output; centred modal or large toast when stream fails; link to troubleshooting guide. _Note: Implemented (structured modal, diagnosis, stderr in Technical details, scenario tests); QA pending._ _Edge cases: Start All partial failure — toast lists failed stream names (up to 5 + "and N more") and first error reason; test API response shape and frontend message._ -- [ ] **Stability** — Confirm 3+ streams stay stable (see TROUBLESHOOTING.md). +- [x] **Stability** — Confirm 3+ streams stay stable (see TROUBLESHOOTING.md). _CONFIRMED working._ -- [ ] **Device change mid-stream / Play after edit** — After changing a stream's device mid-stream, Play on listener page can show "Authentication Failed"; workaround: Start stream on dashboard, refresh listener page. _See TROUBLESHOOTING.md. Should not require delete/recreate._ +- [x] **Device change mid-stream / Play after edit** — Play proxy now checks Icecast response status; non-200 returns 502 with clear message so listener page no longer shows "Authentication Failed". Listener page shows "Stream not running. Start it on the dashboard, then try Play again." No refresh or delete/recreate needed. _See TROUBLESHOOTING.md §1c._ -- [ ] **Stream labels (optional)** — e.g. prefix streams S1, S2, S3; or better naming idea. +- [x] **Stream labels (optional)** — Streams show S1, S2, S3 from server order; label in `getStats()` and on listener page / dashboard. -- [ ] **Sortable streams (optional)** — Drag-and-drop or up/down to reorder stream list on admin dashboard; persist order (e.g. in config). Works with Stream labels; affects frontend for listeners without refresh. +- [x] **Sortable streams (optional)** — Up/down buttons on dashboard; `POST /api/streams/reorder`; order persisted in `config/streams.json` (`_order`); listener page uses same order and S1/S2/S3 labels without refresh. -- [ ] **Contact: WhatsApp** — Enforce country code, build `wa.me/` link. - -- [ ] **UI/UX polish (optional)** — [docs/UI-UX-RECOMMENDATIONS.md](docs/UI-UX-RECOMMENDATIONS.md). +- [x] **Contact: WhatsApp** — Country code enforced (digits 10–15, no leading zero); validation backend + ContactManager; `wa.me/` built on listener page; placeholder "e.g. +44 7123 456789 (country code required)". - [ ] **Update notification UX** — Show notification bell icon when update available; click to open modal with update info and link to latest release. Current: button shows "Updates" and runs manual check on click; should auto-check on load and show bell when updateAvailable=true. +- [ ] **UI/UX polish (optional)** — [docs/UI-UX-RECOMMENDATIONS.md](docs/UI-UX-RECOMMENDATIONS.md). + --- ## Reference diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 969797a..5e94de3 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -302,20 +302,18 @@ If you access the dashboard using a virtual adapter IP (e.g., `http://172.29.16. - Switch IPv4 to DHCP if a leftover static `.8` address exists. 4. Share only the subnet-matching URL (e.g., `http://192.168.100.x:3001/streams`). -#### 1c. "Authentication Failed" after changing device mid-stream +#### 1c. Play fails after changing device mid-stream -**Symptoms:** You edit a stream (change the audio device) while it was playing, then click Play on the listener page and see "Authentication Failed". Play only works again after removing and recreating the stream. +**Symptoms:** You edit a stream (change the audio device) while it was playing, then click Play on the listener page. You may see "Stream not running" or previously "Authentication Failed" (Icecast 401 when the mount has no source). -**Likely cause:** After an edit (device change), the stream is stopped and must be started again. The listener page may still be pointing at the old mount or the stream may be in an error state so the proxy or Icecast returns an error that appears as "Authentication Failed". +**Cause:** After an edit (device change), the stream is stopped and must be started again. The play proxy now returns a clear message instead of piping Icecast’s error, so you no longer see "Authentication Failed". **What to do:** 1. On the **admin dashboard**, open the stream and click **Start** (or use Start All) so the stream is running again with the new device. -2. On the **listener page**, refresh the stream list (reload the page or wait for the next poll), then click Play again. +2. On the **listener page**, click Play again (no refresh or delete/recreate needed). 3. If it still fails, check that the stream shows "LIVE" on the dashboard and that Icecast is running. If the stream is in "ERROR" state, fix the device (e.g. select a working source) and click Start again. 4. Only remove and recreate the stream if the mount is stuck (e.g. Icecast still thinks the old source is connected). Restarting Icecast from the dashboard can clear stuck mounts. -**Note:** This behaviour is under review; the app should not require deleting and recreating the stream when only the device is changed. - #### 2. Windows Firewall Blocking Connections **Solution:** diff --git a/public/components/ContactManager.js b/public/components/ContactManager.js index 5e1ee81..572a576 100644 --- a/public/components/ContactManager.js +++ b/public/components/ContactManager.js @@ -128,24 +128,14 @@ class ContactManager { } } - // Validate WhatsApp if showWhatsapp is enabled + // Validate WhatsApp if showWhatsapp is enabled (country code required for wa.me) if (this.contactDetails.showWhatsapp && this.contactDetails.whatsapp) { - const whatsappRegex = /^\+?[1-9]\d{6,14}$/; - const cleanedWhatsapp = this.contactDetails.whatsapp.replace(/[^\d+]/g, ''); - if (!whatsappRegex.test(cleanedWhatsapp)) { - errors.push('Please enter a valid WhatsApp number (e.g., +1234567890 or 1234567890)'); + const digits = this.contactDetails.whatsapp.replace(/\D/g, ''); + if (digits.length < 10 || digits.length > 15 || digits.startsWith('0')) { + errors.push('WhatsApp: include country code (e.g. +44 7123 456789), no leading zero'); } } - - // Validate WhatsApp if showWhatsapp is enabled - if (this.contactDetails.showWhatsapp && this.contactDetails.whatsapp) { - const phoneRegex = /^\+?[1-9]\d{6,14}$/; - const cleanedWhatsapp = this.contactDetails.whatsapp.replace(/[^\d+]/g, ''); - if (!phoneRegex.test(cleanedWhatsapp)) { - errors.push('Please enter a valid WhatsApp number'); - } - } - + return errors; } @@ -319,7 +309,7 @@ class ContactManager { diff --git a/public/components/FFmpegStreamsManager.js b/public/components/FFmpegStreamsManager.js index 53e5be6..d58114b 100644 --- a/public/components/FFmpegStreamsManager.js +++ b/public/components/FFmpegStreamsManager.js @@ -510,6 +510,42 @@ class FFmpegStreamsManager { } } + /** + * Reorder streams (move up/down). Persists order; listener page uses same order and S1/S2/S3 labels. + */ + async moveStreamUp(index) { + if (index <= 0) return; + await this.reorderStreams(index, index - 1); + } + + async moveStreamDown(index) { + if (index >= this.activeStreams.length - 1) return; + await this.reorderStreams(index, index + 1); + } + + async reorderStreams(fromIndex, toIndex) { + try { + const ids = this.activeStreams.map(s => s.id); + const [moved] = ids.splice(fromIndex, 1); + ids.splice(toIndex, 0, moved); + const response = await fetch('/api/streams/reorder', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ streamIds: ids }) + }); + if (!response.ok) { + const data = await response.json().catch(() => ({})); + throw new Error(data.message || 'Failed to reorder'); + } + await this.loadStreams(); + this.render(); + this.showNotification('Stream order updated', 'success'); + } catch (error) { + console.error('Failed to reorder streams:', error); + this.showNotification(`Failed to reorder: ${error.message}`, 'error'); + } + } + /** * Edit a stream (show edit modal) */ @@ -936,7 +972,7 @@ class FFmpegStreamsManager { `; } - return this.activeStreams.map(stream => { + return this.activeStreams.map((stream, index) => { // Use client-side timer for uptime calculation const clientTimer = this.clientTimers.get(stream.id); let uptime = '0s'; @@ -984,15 +1020,24 @@ class FFmpegStreamsManager { return `
- +
+
+ + ${stream.label || 'S' + (index + 1)} + +
${stream.status === 'running' ? '
' : ''}
-

${stream.name || stream.id}

+

${stream.label ? stream.label + ' — ' : ''}${stream.name || stream.id}

diff --git a/public/streams.html b/public/streams.html index 9ea4493..c061e14 100644 --- a/public/streams.html +++ b/public/streams.html @@ -464,7 +464,7 @@

📞 Contact & Feedback

-

${stream.name || stream.id}

+

${stream.label ? stream.label + ' — ' : ''}${stream.name || stream.id}

@@ -519,7 +519,7 @@

${stream
volume_up - Now playing: ${stream.name || stream.id} + Now playing: ${stream.label ? stream.label + ' ' : ''}${stream.name || stream.id}
@@ -616,14 +616,14 @@

${stream errorMessage = 'Stream format not supported by your browser. Try Chrome, Firefox, or Edge for better AAC support.'; } else if (errorCode === 2) { // MEDIA_ERR_NETWORK if (networkState === 2 || networkState === 3) { - errorMessage = 'Network error loading stream. Check if the Icecast server is running and try again.'; + errorMessage = 'Stream not running. Start it on the dashboard, then try Play again.'; } else { errorMessage = 'Network error loading stream. Check your connection and try again.'; } } else if (errorCode === 3) { // MEDIA_ERR_DECODE errorMessage = 'Stream decode error. The audio format may be corrupted or incompatible.'; } else if (networkState === 2 || networkState === 3) { - errorMessage = 'Stream server unavailable. Please check if the Icecast server is running.'; + errorMessage = 'Stream not running. Start it on the dashboard, then try Play again.'; } this.showNotification(errorMessage, 'error'); @@ -1039,13 +1039,16 @@

Copy Stream URL

} if (contactData.showWhatsapp && contactData.whatsapp) { - const whatsappUrl = `https://wa.me/${contactData.whatsapp.replace(/[^\d]/g, '')}?text=Hi%20from%20LANStreamer!`; - contactItems.push(` + const digits = contactData.whatsapp.replace(/\D/g, ''); + if (digits && !digits.startsWith('0')) { + const whatsappUrl = `https://wa.me/${digits}?text=Hi%20from%20LANStreamer!`; + contactItems.push(` chat WhatsApp Chat `); + } } // Hide the entire contact section if no contact details are available diff --git a/src/routes/contact.js b/src/routes/contact.js index eb575d6..e1379fa 100644 --- a/src/routes/contact.js +++ b/src/routes/contact.js @@ -128,8 +128,8 @@ router.post('/contact-details', async (req, res) => { throw new ValidationError('Invalid phone format', ErrorCodes.INVALID_FORMAT); } - if (whatsapp && !isValidPhone(whatsapp)) { - throw new ValidationError('Invalid WhatsApp number format', ErrorCodes.INVALID_FORMAT); + if (whatsapp && !isValidWhatsApp(whatsapp)) { + throw new ValidationError('WhatsApp number must include country code (e.g. +44 7123 456789). Use digits only with country code, no leading zero.', ErrorCodes.INVALID_FORMAT); } // Prepare contact details @@ -206,6 +206,17 @@ function isValidPhone(phone) { return phoneRegex.test(cleaned); } +/** + * Validate WhatsApp number for wa.me link: digits only with country code (no leading zero). + * E.g. +44 7123 456789 → 447123456789 (valid). 07123456789 (invalid, missing country code). + */ +function isValidWhatsApp(value) { + const digits = (value || '').replace(/\D/g, ''); + if (digits.length < 10 || digits.length > 15) return false; + if (digits.startsWith('0')) return false; // leading zero usually means missing country code + return /^[1-9]\d{9,14}$/.test(digits); +} + /** * POST /api/upload-event-image * Upload event image diff --git a/src/routes/streams.js b/src/routes/streams.js index 50e048e..590f36d 100644 --- a/src/routes/streams.js +++ b/src/routes/streams.js @@ -107,19 +107,49 @@ router.post('/restart', async (req, res) => { /** * @route GET /api/streams/play/:streamId * @description Proxy Icecast stream for same-origin playback (avoids CORS and firewall on Icecast port). + * When Icecast returns 4xx (e.g. 401/404 for no source), respond with 502 and a clear message so the + * listener page does not show "Authentication Failed" (e.g. after device change mid-stream). * @access Public */ +const PLAY_PROXY_TIMEOUT_MS = 10000; + router.get('/play/:streamId', (req, res) => { const { streamId } = req.params; const port = IcecastService.getActualPort() || 8000; const url = `http://127.0.0.1:${port}/${encodeURIComponent(streamId)}`; - http.get(url, (upstream) => { + const clientRequest = http.get(url, { timeout: PLAY_PROXY_TIMEOUT_MS }, (upstream) => { + const status = upstream.statusCode || 0; + if (status !== 200) { + try { + upstream.resume(); + } catch (e) { + logger.warn('Stream proxy: failed to consume upstream body', { streamId, error: e.message }); + } + logger.warn('Stream proxy: Icecast returned non-200', { streamId, status }); + if (!res.headersSent) { + res.status(502).json({ + error: 'Stream not available', + message: 'Stream is not running or mount not ready. Start the stream on the dashboard, then try Play again.' + }); + } + return; + } const contentType = upstream.headers['content-type'] || 'audio/mpeg'; res.setHeader('Content-Type', contentType); upstream.pipe(res); - }).on('error', (err) => { + }); + clientRequest.on('timeout', () => { + clientRequest.destroy(); + if (!res.headersSent) { + logger.warn('Stream proxy: timeout', { streamId }); + res.status(502).json({ error: 'Stream unavailable', message: 'Stream connection timed out.' }); + } + }); + clientRequest.on('error', (err) => { logger.warn('Stream proxy error:', { streamId, message: err.message }); - res.status(502).json({ error: 'Stream unavailable', message: err.message }); + if (!res.headersSent) { + res.status(502).json({ error: 'Stream unavailable', message: err.message }); + } }); }); @@ -157,6 +187,25 @@ router.post('/cleanup', (req, res) => { } }); +/** + * @route POST /api/streams/reorder + * @description Set display order of streams (S1, S2, S3). Persists to config; affects dashboard and listener page. + * @access Public + */ +router.post('/reorder', async (req, res) => { + try { + const { streamIds } = req.body; + if (!Array.isArray(streamIds)) { + return res.status(400).json({ message: 'streamIds must be an array' }); + } + streamingService.setStreamOrder(streamIds); + res.status(200).json({ message: 'Stream order updated', streamIds }); + } catch (error) { + logger.error('Error reordering streams:', error); + res.status(500).json({ message: 'Error reordering streams', error: error.message }); + } +}); + /** * @route POST /api/streams/update * @description Update a stream's configuration. diff --git a/src/services/StreamingService.js b/src/services/StreamingService.js index 13529d6..9b4bdda 100644 --- a/src/services/StreamingService.js +++ b/src/services/StreamingService.js @@ -15,6 +15,8 @@ const __dirname = dirname(__filename) class StreamingService { constructor() { this.activeStreams = {} + /** @type {string[]} Display order of stream IDs (S1=first, S2=second, etc.). Persisted in streams.json as _order. */ + this.streamOrder = [] this.ffmpegService = FFmpegService this.streamsConfigPath = join(process.cwd(), 'config', 'streams.json') this.lastIcecastStatus = 'unknown' @@ -42,19 +44,25 @@ class StreamingService { if (fs.existsSync(this.streamsConfigPath)) { const data = fs.readFileSync(this.streamsConfigPath, 'utf8') const persistentStreams = JSON.parse(data) - - // Convert persistent streams to active streams (without process references) - Object.keys(persistentStreams).forEach(streamId => { + const streamIds = Object.keys(persistentStreams).filter(k => k !== '_order') + + this.streamOrder = Array.isArray(persistentStreams._order) + ? persistentStreams._order.filter(id => streamIds.includes(id)) + : streamIds + const missingInOrder = streamIds.filter(id => !this.streamOrder.includes(id)) + this.streamOrder.push(...missingInOrder) + + streamIds.forEach(streamId => { const stream = persistentStreams[streamId] this.activeStreams[streamId] = { ...stream, - status: 'stopped', // Mark as stopped since server restarted + status: 'stopped', ffmpegProcess: null, - needsRestart: true // Flag to indicate this stream needs to be restarted + needsRestart: true } }) - - logger.info(`Loaded ${Object.keys(persistentStreams).length} persistent streams`) + + logger.info(`Loaded ${streamIds.length} persistent streams, order: ${this.streamOrder.length} entries`) } } catch (error) { logger.error('Failed to load persistent streams:', error) @@ -66,14 +74,12 @@ class StreamingService { */ savePersistentStreams() { try { - // Ensure config directory exists const configDir = dirname(this.streamsConfigPath) if (!fs.existsSync(configDir)) { fs.mkdirSync(configDir, { recursive: true }) } - // Save only the stream configuration (not process references) - const persistentData = {} + const persistentData = { _order: this.streamOrder } Object.keys(this.activeStreams).forEach(streamId => { const stream = this.activeStreams[streamId] persistentData[streamId] = { @@ -87,7 +93,7 @@ class StreamingService { }) fs.writeFileSync(this.streamsConfigPath, JSON.stringify(persistentData, null, 2)) - logger.info(`Saved ${Object.keys(persistentData).length} streams to persistent storage`) + logger.info(`Saved ${Object.keys(persistentData).length - 1} streams to persistent storage`) } catch (error) { logger.error('Failed to save persistent streams:', error) } @@ -222,6 +228,9 @@ class StreamingService { exitCode: null, exitSignal: null } + if (!this.streamOrder.includes(streamId)) { + this.streamOrder.push(streamId) + } // Save to persistent storage this.savePersistentStreams() @@ -872,8 +881,11 @@ class StreamingService { config: { ...stream.config, name: newName || updates.name } } - // Remove old stream entry + // Remove old stream entry and update order (replace old id with new id) delete this.activeStreams[streamId] + const orderIdx = this.streamOrder.indexOf(streamId) + if (orderIdx !== -1) this.streamOrder[orderIdx] = newStreamId + else this.streamOrder.push(newStreamId) logger.info(`Stream ID updated from ${streamId} to ${newStreamId}`) } @@ -949,8 +961,9 @@ class StreamingService { await this.stopStream(streamId) } - // Remove from active streams + // Remove from active streams and from display order delete this.activeStreams[streamId] + this.streamOrder = this.streamOrder.filter(id => id !== streamId) // Save to persistent storage (without the deleted stream) this.savePersistentStreams() @@ -1347,17 +1360,25 @@ class StreamingService { return s }) - // Count based on verified status - const runningStreams = verifiedStreams.filter(s => s.status === 'running') - const errorStreams = verifiedStreams.filter(s => s.status === 'error') + // Order by streamOrder (S1=first, S2=second, etc.); streams not in order go at end + const orderMap = new Map(this.streamOrder.map((id, i) => [id, i])) + const ordered = [...verifiedStreams].sort((a, b) => { + const ai = orderMap.has(a.id) ? orderMap.get(a.id) : this.streamOrder.length + const bi = orderMap.has(b.id) ? orderMap.get(b.id) : this.streamOrder.length + return ai - bi + }) + + const runningStreams = ordered.filter(s => s.status === 'running') + const errorStreams = ordered.filter(s => s.status === 'error') return { - total: verifiedStreams.length, + total: ordered.length, running: runningStreams.length, errors: errorStreams.length, - streams: verifiedStreams.map(s => ({ + streams: ordered.map((s, index) => ({ id: s.id, name: s.name, + label: `S${index + 1}`, status: s.status, deviceId: s.deviceId, inputFile: s.inputFile, @@ -1371,6 +1392,17 @@ class StreamingService { })) } } + + /** + * Set display order of streams (for sortable list). Persists to config. + * @param {string[]} streamIds - Ordered list of stream IDs + */ + setStreamOrder(streamIds) { + const valid = streamIds.filter(id => this.activeStreams[id]) + this.streamOrder = valid.length ? [...new Set(valid)] : Object.keys(this.activeStreams) + this.savePersistentStreams() + logger.info('Stream order updated', { order: this.streamOrder }) + } } export default new StreamingService() \ No newline at end of file diff --git a/tests/integration/stream-reorder.test.js b/tests/integration/stream-reorder.test.js new file mode 100644 index 0000000..6d86330 --- /dev/null +++ b/tests/integration/stream-reorder.test.js @@ -0,0 +1,29 @@ +/** + * Integration test: POST /api/streams/reorder + * Guards: Reorder route exists, accepts streamIds array, returns 200 + */ +import request from 'supertest'; +import app from '../../src/server.js'; + +describe('POST /api/streams/reorder', () => { + it('returns 200 when streamIds is an array', async () => { + const res = await request(app) + .post('/api/streams/reorder') + .set('Content-Type', 'application/json') + .send({ streamIds: [] }); + + expect(res.status).toBe(200); + expect(res.body).toHaveProperty('message'); + expect(res.body).toHaveProperty('streamIds'); + expect(Array.isArray(res.body.streamIds)).toBe(true); + }); + + it('returns 400 when streamIds is not an array', async () => { + const res = await request(app) + .post('/api/streams/reorder') + .set('Content-Type', 'application/json') + .send({ streamIds: 'not-an-array' }); + + expect(res.status).toBe(400); + }); +});