From 68e6ac5ac1811751b9f538fe2a6ad6c4c4ff2870 Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Sun, 5 Apr 2026 18:27:15 +0000 Subject: [PATCH] Autosave: 20260405-182715 --- backend/src/routes/file.js | 50 +- backend/src/services/file.js | 366 ++- frontend/src/helpers/modularInteractionHub.ts | 12 + frontend/src/pages/interaction-hub.tsx | 2023 ++++++++++++++++- 4 files changed, 2261 insertions(+), 190 deletions(-) diff --git a/backend/src/routes/file.js b/backend/src/routes/file.js index ddd2bc0..6d2e933 100644 --- a/backend/src/routes/file.js +++ b/backend/src/routes/file.js @@ -1,26 +1,23 @@ const express = require('express'); -const config = require('../config'); -const path = require('path'); const passport = require('passport'); const services = require('../services/file'); + const router = express.Router(); router.get('/download', (req, res) => { - if (process.env.NODE_ENV == "production" || process.env.NEXT_PUBLIC_BACK_API) { + if (process.env.NODE_ENV == 'production' || process.env.NEXT_PUBLIC_BACK_API) { services.downloadGCloud(req, res); - } - else { + } else { services.downloadLocal(req, res); } }); -router.post('/upload/:table/:field', passport.authenticate('jwt', {session: false}), (req, res) => { +router.post('/upload/:table/:field', passport.authenticate('jwt', { session: false }), (req, res) => { const fileName = `${req.params.table}/${req.params.field}`; - if (process.env.NODE_ENV == "production" || process.env.NEXT_PUBLIC_BACK_API) { + if (process.env.NODE_ENV == 'production' || process.env.NEXT_PUBLIC_BACK_API) { services.uploadGCloud(fileName, req, res); - } - else { + } else { services.uploadLocal(fileName, { entity: null, maxFileSize: 10 * 1024 * 1024, @@ -29,4 +26,39 @@ router.post('/upload/:table/:field', passport.authenticate('jwt', {session: fals } }); +router.post('/upload-public/:table/:field', (req, res) => { + const fileName = `${req.params.table}/${req.params.field}`; + + if (process.env.NODE_ENV == 'production' || process.env.NEXT_PUBLIC_BACK_API) { + services.uploadGCloud(fileName, req, res); + } else { + services.uploadLocal(fileName, { + entity: null, + maxFileSize: 50 * 1024 * 1024, + folderIncludesAuthenticationUid: false, + allowAnonymous: true, + allowedMimePrefixes: ['audio/', 'video/'], + })(req, res); + } +}); + +router.post('/fetch-public-playlist', express.json({ limit: '100kb' }), async (req, res) => { + const playlistUrl = typeof req.body?.url === 'string' ? req.body.url.trim() : ''; + + if (!playlistUrl) { + res.status(400).send({ message: 'A playlist URL is required.' }); + return; + } + + try { + const result = await services.fetchPublicTextFile(playlistUrl); + res.status(200).send(result); + } catch (error) { + console.error('Failed to fetch public playlist URL:', { url: playlistUrl, error: error.message || error }); + res.status(error.statusCode || 500).send({ + message: error.message || 'Failed to fetch the remote playlist URL.', + }); + } +}); + module.exports = router; diff --git a/backend/src/services/file.js b/backend/src/services/file.js index 597be30..ca9011f 100644 --- a/backend/src/services/file.js +++ b/backend/src/services/file.js @@ -1,8 +1,13 @@ +const axios = require('axios'); +const dns = require('dns').promises; const formidable = require('formidable'); const fs = require('fs'); +const net = require('net'); const config = require('../config'); const path = require('path'); -const { format } = require("util"); +const { format } = require('util'); + +const MAX_REMOTE_PLAYLIST_BYTES = 2 * 1024 * 1024; const ensureDirectoryExistence = (filePath) => { const dirname = path.dirname(filePath); @@ -13,7 +18,39 @@ const ensureDirectoryExistence = (filePath) => { ensureDirectoryExistence(dirname); fs.mkdirSync(dirname); -} +}; + +const getUploadedFile = (files) => { + if (!files || !files.file) { + return null; + } + + return Array.isArray(files.file) ? files.file[0] : files.file; +}; + +const getUploadedFilePath = (file) => { + if (!file) { + return null; + } + + return file.filepath || file.path || null; +}; + +const getUploadedFileType = (file) => { + if (!file) { + return ''; + } + + return file.mimetype || file.type || ''; +}; + +const isMimeTypeAllowed = (mimeType, validations) => { + if (!validations || !validations.allowedMimePrefixes || validations.allowedMimePrefixes.length === 0) { + return true; + } + + return validations.allowedMimePrefixes.some((prefix) => mimeType.startsWith(prefix)); +}; const uploadLocal = ( folder, @@ -21,30 +58,24 @@ const uploadLocal = ( entity: null, maxFileSize: null, folderIncludesAuthenticationUid: false, + allowAnonymous: false, + allowedMimePrefixes: null, }, ) => { return (req, res) => { - if (!req.currentUser) { + if (!req.currentUser && !validations.allowAnonymous) { res.sendStatus(403); return; } - if ( - validations.entity - ) { + if (validations.entity) { res.sendStatus(403); return; } if (validations.folderIncludesAuthenticationUid) { - folder = folder.replace( - ':userId', - req.currentUser.authenticationUid, - ); - if ( - !req.currentUser.authenticationUid || - !folder.includes(req.currentUser.authenticationUid) - ) { + folder = folder.replace(':userId', req.currentUser.authenticationUid); + if (!req.currentUser.authenticationUid || !folder.includes(req.currentUser.authenticationUid)) { res.sendStatus(403); return; } @@ -58,140 +89,311 @@ const uploadLocal = ( } form.parse(req, function (err, fields, files) { - const filename = String(fields.filename); - const fileTempUrl = files.file.path; + if (err) { + console.error('File upload parse failed:', err); + res.status(500).send(err); + return; + } - if (!filename) { + const filename = String(fields.filename); + const uploadedFile = getUploadedFile(files); + const fileTempUrl = getUploadedFilePath(uploadedFile); + const mimeType = getUploadedFileType(uploadedFile); + + if (!fileTempUrl || !uploadedFile) { + res.status(400).send({ message: 'Please upload a file.' }); + return; + } + + if (!isMimeTypeAllowed(mimeType, validations)) { + fs.unlinkSync(fileTempUrl); + res.status(400).send({ message: 'Invalid file type.' }); + return; + } + + if (!filename || filename === 'undefined') { fs.unlinkSync(fileTempUrl); res.sendStatus(500); return; } - const privateUrl = path.join( - form.uploadDir, - folder, - filename, - ); - ensureDirectoryExistence(privateUrl); - fs.renameSync(fileTempUrl, privateUrl); - res.sendStatus(200); + const privateUrl = path.join(folder, filename); + const destinationPath = path.join(form.uploadDir, privateUrl); + ensureDirectoryExistence(destinationPath); + fs.renameSync(fileTempUrl, destinationPath); + res.status(200).send({ privateUrl }); }); form.on('error', function (err) { + console.error('File upload failed:', err); res.status(500).send(err); }); - } -} + }; +}; const downloadLocal = async (req, res) => { - const privateUrl = req.query.privateUrl; - if (!privateUrl) { - return res.sendStatus(404); + const privateUrl = req.query.privateUrl; + if (!privateUrl) { + return res.sendStatus(404); + } + return res.download(path.join(config.uploadDir, privateUrl)); +}; + +const isPrivateIpv4 = (ip) => { + const [firstOctet, secondOctet] = ip.split('.').map((segment) => Number(segment)); + + if (firstOctet === 10 || firstOctet === 127 || firstOctet === 0) { + return true; + } + + if (firstOctet === 169 && secondOctet === 254) { + return true; + } + + if (firstOctet === 172 && secondOctet >= 16 && secondOctet <= 31) { + return true; + } + + if (firstOctet === 192 && secondOctet === 168) { + return true; + } + + return false; +}; + +const isPrivateIpv6 = (ip) => { + const loweredIp = ip.toLowerCase(); + + return ( + loweredIp === '::1' || + loweredIp === '::' || + loweredIp.startsWith('fc') || + loweredIp.startsWith('fd') || + loweredIp.startsWith('fe80') + ); +}; + +const isPrivateIp = (ip) => { + const family = net.isIP(ip); + + if (family === 4) { + return isPrivateIpv4(ip); + } + + if (family === 6) { + return isPrivateIpv6(ip); + } + + return true; +}; + +const assertRemoteUrlIsPublic = async (remoteUrl) => { + let parsedUrl; + + try { + parsedUrl = new URL(remoteUrl); + } catch (error) { + const invalidUrlError = new Error('Use a valid http:// or https:// playlist URL.'); + invalidUrlError.statusCode = 400; + throw invalidUrlError; + } + + if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') { + const invalidProtocolError = new Error('Only http:// and https:// playlist URLs are supported.'); + invalidProtocolError.statusCode = 400; + throw invalidProtocolError; + } + + const hostname = parsedUrl.hostname.toLowerCase(); + if (hostname === 'localhost' || hostname.endsWith('.local')) { + const localhostError = new Error('Local network playlist URLs are not allowed.'); + localhostError.statusCode = 400; + throw localhostError; + } + + if (net.isIP(hostname) && isPrivateIp(hostname)) { + const privateIpError = new Error('Private network playlist URLs are not allowed.'); + privateIpError.statusCode = 400; + throw privateIpError; + } + + const resolvedAddresses = await dns.lookup(parsedUrl.hostname, { all: true }); + + if (!resolvedAddresses.length) { + const dnsError = new Error('Could not resolve the playlist host.'); + dnsError.statusCode = 400; + throw dnsError; + } + + if (resolvedAddresses.some((entry) => isPrivateIp(entry.address))) { + const resolvedPrivateIpError = new Error('Playlist host resolves to a private network address.'); + resolvedPrivateIpError.statusCode = 400; + throw resolvedPrivateIpError; + } + + return parsedUrl.toString(); +}; + +const fetchPublicTextFile = async (remoteUrl) => { + const safeUrl = await assertRemoteUrlIsPublic(remoteUrl); + + try { + const response = await axios.get(safeUrl, { + responseType: 'text', + timeout: 15000, + maxContentLength: MAX_REMOTE_PLAYLIST_BYTES, + maxBodyLength: MAX_REMOTE_PLAYLIST_BYTES, + transformResponse: [(data) => data], + headers: { + Accept: 'application/json, application/x-mpegURL, application/vnd.apple.mpegurl, audio/mpegurl, text/plain;q=0.9,*/*;q=0.5', + }, + }); + + if (typeof response.data !== 'string') { + const responseTypeError = new Error('The remote playlist must return text-based JSON, M3U, or M3U8 content.'); + responseTypeError.statusCode = 400; + throw responseTypeError; } - res.download(path.join(config.uploadDir, privateUrl)); -} + + return { + url: safeUrl, + body: response.data, + contentType: response.headers['content-type'] || '', + }; + } catch (error) { + if (error.response) { + console.error('Remote playlist fetch failed:', { + url: safeUrl, + status: error.response.status, + data: error.response.data, + }); + const remoteResponseError = new Error(`Remote server responded with status ${error.response.status}.`); + remoteResponseError.statusCode = error.response.status; + throw remoteResponseError; + } + + if (error.code === 'ERR_BAD_RESPONSE' || error.code === 'ERR_BAD_REQUEST') { + console.error('Remote playlist response handling failed:', { url: safeUrl, message: error.message }); + const remoteBodyError = new Error('The remote playlist response could not be processed.'); + remoteBodyError.statusCode = 400; + throw remoteBodyError; + } + + if (error.code === 'ECONNABORTED') { + console.error('Remote playlist request timed out:', { url: safeUrl, message: error.message }); + const timeoutError = new Error('Timed out while fetching the remote playlist.'); + timeoutError.statusCode = 504; + throw timeoutError; + } + + if (error.message && error.message.includes('maxContentLength')) { + console.error('Remote playlist exceeded size limit:', { url: safeUrl, message: error.message }); + const sizeError = new Error('Remote playlist is larger than the 2 MB import limit.'); + sizeError.statusCode = 413; + throw sizeError; + } + + if (error.statusCode) { + throw error; + } + + console.error('Remote playlist fetch failed:', { url: safeUrl, error }); + const unknownError = new Error('Failed to fetch the remote playlist URL.'); + unknownError.statusCode = 500; + throw unknownError; + } +}; const initGCloud = () => { - const processFile = require("../middlewares/upload"); - const { Storage } = require("@google-cloud/storage"); + const processFile = require('../middlewares/upload'); + const { Storage } = require('@google-cloud/storage'); - const crypto = require('crypto') - const hash = config.gcloud.hash - - const privateKey = process.env.GC_PRIVATE_KEY.replace(/\\\n/g, "\n"); + const hash = config.gcloud.hash; + const privateKey = process.env.GC_PRIVATE_KEY.replace(/\\\n/g, '\n'); const storage = new Storage({ - projectId: process.env.GC_PROJECT_ID, - credentials: { - client_email: process.env.GC_CLIENT_EMAIL, - private_key: privateKey - } + projectId: process.env.GC_PROJECT_ID, + credentials: { + client_email: process.env.GC_CLIENT_EMAIL, + private_key: privateKey, + }, }); const bucket = storage.bucket(config.gcloud.bucket); - return {hash, bucket, processFile}; -} + return { hash, bucket, processFile }; +}; const uploadGCloud = async (folder, req, res) => { try { - const {hash, bucket, processFile} = initGCloud(); + const { hash, bucket, processFile } = initGCloud(); await processFile(req, res); - let buffer = await req.file.buffer; - let filename = await req.body.filename; + const buffer = await req.file.buffer; + const filename = await req.body.filename; if (!req.file) { - return res.status(400).send({ message: "Please upload a file!" }); + return res.status(400).send({ message: 'Please upload a file!' }); } - let path = `${hash}/${folder}/${filename}`; - let blob = bucket.file(path); - - console.log(path); + const fullPath = `${hash}/${folder}/${filename}`; + const blob = bucket.file(fullPath); const blobStream = blob.createWriteStream({ resumable: false, }); - blobStream.on("error", (err) => { - console.log('Upload error'); - console.log(err.message); + blobStream.on('error', (err) => { + console.error('Upload error:', err.message); res.status(500).send({ message: err.message }); }); - console.log(`https://storage.googleapis.com/${bucket.name}/${blob.name}`); - - blobStream.on("finish", async (data) => { - const publicUrl = format( - `https://storage.googleapis.com/${bucket.name}/${blob.name}` - ); + blobStream.on('finish', async () => { + const publicUrl = format(`https://storage.googleapis.com/${bucket.name}/${blob.name}`); res.status(200).send({ - message: "Uploaded the file successfully: " + path, + message: `Uploaded the file successfully: ${fullPath}`, url: publicUrl, }); }); - blobStream.end(buffer) + blobStream.end(buffer); } catch (err) { - console.log(err); - + console.error('Could not upload the file:', err); res.status(500).send({ - message: `Could not upload the file. ${err}` + message: `Could not upload the file. ${err}`, }); } -} +}; const downloadGCloud = async (req, res) => { try { - const {hash, bucket, processFile} = initGCloud(); + const { hash, bucket } = initGCloud(); const privateUrl = await req.query.privateUrl; const filePath = `${hash}/${privateUrl}`; - const file = bucket.file(filePath) + const file = bucket.file(filePath); const fileExists = await file.exists(); if (fileExists[0]) { const stream = file.createReadStream(); stream.pipe(res); - } - else { + } else { res.status(404).send({ - message: "Could not download the file. " + err, - }); + message: 'Could not download the requested file.', + }); } } catch (err) { res.status(404).send({ - message: "Could not download the file. " + err, + message: `Could not download the file. ${err}`, }); } -} +}; const deleteGCloud = async (privateUrl) => { try { - const {hash, bucket, processFile} = initGCloud(); + const { hash, bucket } = initGCloud(); const filePath = `${hash}/${privateUrl}`; - const file = bucket.file(filePath) + const file = bucket.file(filePath); const fileExists = await file.exists(); if (fileExists[0]) { @@ -200,7 +402,7 @@ const deleteGCloud = async (privateUrl) => { } catch (err) { console.log(`Cannot find the file ${privateUrl}`); } -} +}; module.exports = { initGCloud, @@ -208,6 +410,6 @@ module.exports = { downloadLocal, deleteGCloud, uploadGCloud, - downloadGCloud -} - + downloadGCloud, + fetchPublicTextFile, +}; diff --git a/frontend/src/helpers/modularInteractionHub.ts b/frontend/src/helpers/modularInteractionHub.ts index 85ee5d1..020341b 100644 --- a/frontend/src/helpers/modularInteractionHub.ts +++ b/frontend/src/helpers/modularInteractionHub.ts @@ -12,6 +12,7 @@ export type HubLink = { export type MediaPresetType = 'radio' | 'tv'; export type MediaPresetMode = 'audio' | 'video' | 'embed'; +export type MediaPresetSourceKind = 'manual' | 'imported' | 'uploaded'; export type MediaPreset = { id: string; @@ -21,6 +22,15 @@ export type MediaPreset = { notes: string; mode: MediaPresetMode; isSample?: boolean; + sourceKind?: MediaPresetSourceKind; + privateUrl?: string; + originalFilename?: string; + durationSeconds?: number; + thumbnailUrl?: string; + folder?: string; + remotePlaylistUrl?: string; + remoteRefreshIntervalMinutes?: number; + lastRemoteRefreshAt?: string; }; export type AdminShortcut = { @@ -122,6 +132,7 @@ export const starterMediaPresets: MediaPreset[] = [ notes: 'Replace this with your live radio stream URL when you are ready.', mode: 'audio', isSample: true, + sourceKind: 'manual', }, { id: 'sample-tv', @@ -131,6 +142,7 @@ export const starterMediaPresets: MediaPreset[] = [ notes: 'This placeholder shows the TV widget layout until you paste a real embed or video stream.', mode: 'video', isSample: true, + sourceKind: 'manual', }, ]; diff --git a/frontend/src/pages/interaction-hub.tsx b/frontend/src/pages/interaction-hub.tsx index b0a3e54..37b5f9a 100644 --- a/frontend/src/pages/interaction-hub.tsx +++ b/frontend/src/pages/interaction-hub.tsx @@ -1,12 +1,20 @@ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { ReactElement } from 'react'; import Head from 'next/head'; import Link from 'next/link'; +import axios from 'axios'; import { mdiArrowLeft, mdiBroadcast, + mdiClose, + mdiContentSaveOutline, mdiDeleteOutline, + mdiDrag, + mdiFileDownloadOutline, + mdiFolderUploadOutline, + mdiLinkVariant, mdiOpenInNew, + mdiPencilOutline, mdiPlus, mdiRadioTower, mdiRobotOutline, @@ -17,17 +25,26 @@ import BaseButton from '../components/BaseButton'; import BaseIcon from '../components/BaseIcon'; import CardBox from '../components/CardBox'; import LayoutGuest from '../layouts/Guest'; -import { getPageTitle } from '../config'; +import { baseURLApi, getPageTitle } from '../config'; import { adminShortcuts, MediaPreset, MediaPresetMode, + MediaPresetSourceKind, MediaPresetType, modularInteractionLinks, starterMediaPresets, } from '../helpers/modularInteractionHub'; const MEDIA_PRESETS_STORAGE_KEY = 'modular-interaction-media-presets'; +const PUBLIC_MEDIA_UPLOAD_TABLE = 'interaction_hub'; +const PUBLIC_MEDIA_UPLOAD_FIELD = 'media'; +const PUBLIC_MEDIA_PRIVATE_PREFIX = `${PUBLIC_MEDIA_UPLOAD_TABLE}/${PUBLIC_MEDIA_UPLOAD_FIELD}`; +const AUDIO_EXTENSIONS = ['mp3', 'aac', 'm4a', 'ogg', 'oga', 'wav', 'flac', 'weba']; +const VIDEO_EXTENSIONS = ['mp4', 'webm', 'ogg', 'ogv', 'mov', 'm3u8', 'mpd']; +const PLAYLIST_EXTENSIONS = ['json', 'm3u', 'm3u8']; +const REMOTE_REFRESH_INTERVAL_OPTIONS = [0, 15, 30, 60, 180, 720]; +const REMOTE_REFRESH_POLL_INTERVAL_MS = 60 * 1000; type FormState = { type: MediaPresetType; @@ -35,16 +52,48 @@ type FormState = { url: string; notes: string; mode: MediaPresetMode; + folder: string; }; type FormErrors = Partial>; +type PlaylistJsonPayload = { + presets?: unknown[]; +}; + +type M3uMetadata = { + title?: string; + groupTitle?: string; + tvgName?: string; +}; + +type RemotePlaylistResponse = { + body?: string; + contentType?: string; + url?: string; +}; + +type RemoteSyncState = { + isRefreshing?: boolean; + lastMessage?: string; + lastError?: string; +}; + +type RemoteSourceSummary = { + url: string; + presetCount: number; + folderLabels: string[]; + refreshMinutes?: number; + lastRemoteRefreshAt?: string; +}; + const defaultFormState: FormState = { type: 'radio', title: '', url: '', notes: '', mode: 'audio', + folder: '', }; const sectionMeta: Record = { @@ -86,6 +135,56 @@ const isValidUrl = (value: string) => { } }; +const getFileExtension = (value: string) => { + const cleanedValue = value.split('?')[0].split('#')[0]; + const segments = cleanedValue.split('.'); + return segments.length > 1 ? segments.pop()?.toLowerCase() ?? '' : ''; +}; + +const deriveTitleFromUrl = (url: string) => { + try { + const parsed = new URL(url); + const lastSegment = parsed.pathname.split('/').filter(Boolean).pop(); + + if (lastSegment) { + return decodeURIComponent(lastSegment).replace(/\.[^.]+$/, '').replace(/[-_]+/g, ' ').trim(); + } + + return parsed.hostname.replace(/^www\./, '').trim(); + } catch { + return 'Imported stream'; + } +}; + +const deriveTitleFromFilename = (filename: string) => { + return filename.replace(/\.[^.]+$/, '').replace(/[-_]+/g, ' ').trim() || 'Uploaded media'; +}; + +const inferTypeFromSource = (source: string, mimeType = '', title = ''): MediaPresetType => { + const loweredSource = source.toLowerCase(); + const loweredMimeType = mimeType.toLowerCase(); + const loweredTitle = title.toLowerCase(); + const extension = getFileExtension(loweredSource); + + if (loweredMimeType.startsWith('audio/') || AUDIO_EXTENSIONS.includes(extension)) { + return 'radio'; + } + + if ( + loweredMimeType.startsWith('video/') || + VIDEO_EXTENSIONS.includes(extension) || + loweredSource.includes('youtube.com') || + loweredSource.includes('youtu.be') || + loweredSource.includes('vimeo.com') || + loweredTitle.includes(' tv') || + loweredTitle.includes('television') + ) { + return 'tv'; + } + + return 'radio'; +}; + const inferMode = (type: MediaPresetType, url: string, fallback: MediaPresetMode) => { if (type === 'radio') { return 'audio'; @@ -97,12 +196,21 @@ const inferMode = (type: MediaPresetType, url: string, fallback: MediaPresetMode lowered.endsWith('.mp4') || lowered.endsWith('.webm') || lowered.endsWith('.ogg') || - lowered.includes('.mp4?') + lowered.endsWith('.m3u8') || + lowered.endsWith('.mpd') || + lowered.includes('.mp4?') || + lowered.includes('.m3u8?') ) { return 'video'; } - if (lowered.includes('youtube.com/watch?v=')) { + if ( + lowered.includes('youtube.com/watch?v=') || + lowered.includes('youtube.com/embed/') || + lowered.includes('youtu.be/') || + lowered.includes('vimeo.com/') || + lowered.includes('embed') + ) { return 'embed'; } @@ -123,6 +231,15 @@ const normalizeTvUrl = (url: string, mode: MediaPresetMode) => { } } + if (url.includes('youtu.be/')) { + const parsed = new URL(url); + const videoId = parsed.pathname.replace('/', ''); + + if (videoId) { + return `https://www.youtube.com/embed/${videoId}`; + } + } + return url; }; @@ -134,12 +251,558 @@ const getPreviewLabel = (preset: MediaPreset) => { return preset.mode === 'embed' ? 'TV embed preview' : 'TV video preview'; }; +const getPresetFingerprint = (preset: Pick) => { + return [preset.type, preset.title.trim().toLowerCase(), preset.url.trim().toLowerCase()].join('::'); +}; + +const getDownloadUrl = (privateUrl: string) => `${baseURLApi}/file/download?privateUrl=${encodeURIComponent(privateUrl)}`; + +const getPrivateUrlFromPreset = (preset: MediaPreset) => { + if (preset.privateUrl) { + return preset.privateUrl; + } + + try { + const parsed = new URL(preset.url, typeof window !== 'undefined' ? window.location.origin : 'http://localhost'); + const privateUrl = parsed.searchParams.get('privateUrl'); + return privateUrl || ''; + } catch { + return ''; + } +}; + +const isUploadedPreset = (preset: MediaPreset) => { + if (preset.sourceKind === 'uploaded') { + return true; + } + + const privateUrl = getPrivateUrlFromPreset(preset); + return privateUrl.includes(PUBLIC_MEDIA_PRIVATE_PREFIX); +}; + +const isPlaylistFilename = (value: string) => PLAYLIST_EXTENSIONS.includes(getFileExtension(value)); + +const isPlaylistFile = (file: File) => { + const mimeType = file.type.toLowerCase(); + return ( + isPlaylistFilename(file.name) || + mimeType.includes('json') || + mimeType.includes('mpegurl') || + mimeType.includes('x-mpegurl') || + mimeType.startsWith('text/') + ); +}; + +const formatDuration = (durationSeconds?: number) => { + if (!durationSeconds || !Number.isFinite(durationSeconds) || durationSeconds <= 0) { + return ''; + } + + const totalSeconds = Math.round(durationSeconds); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + if (hours > 0) { + return [hours, minutes.toString().padStart(2, '0'), seconds.toString().padStart(2, '0')].join(':'); + } + + return [minutes, seconds.toString().padStart(2, '0')].join(':'); +}; + +const normalizeFolderName = (value?: string) => value?.trim() ?? ''; + +const getPresetFolderLabel = (preset: Pick) => normalizeFolderName(preset.folder) || 'Ungrouped'; + +const formatRefreshInterval = (minutes?: number) => { + if (!minutes || minutes <= 0) { + return 'Manual refresh only'; + } + + if (minutes < 60) { + return `Every ${minutes} min`; + } + + if (minutes % 60 === 0) { + const hours = minutes / 60; + return `Every ${hours} hour${hours === 1 ? '' : 's'}`; + } + + return `Every ${minutes} min`; +}; + +const formatSyncTimestamp = (value?: string) => { + if (!value) { + return 'Not refreshed yet'; + } + + const parsed = new Date(value); + + if (Number.isNaN(parsed.getTime())) { + return 'Not refreshed yet'; + } + + return parsed.toLocaleString(); +}; + +const createPlaylistExportPayload = (candidatePresets: MediaPreset[]) => ({ + exportedAt: new Date().toISOString(), + presetCount: candidatePresets.length, + presets: candidatePresets.map( + ({ + type, + title, + url, + notes, + mode, + sourceKind, + privateUrl, + originalFilename, + durationSeconds, + thumbnailUrl, + folder, + remotePlaylistUrl, + remoteRefreshIntervalMinutes, + lastRemoteRefreshAt, + }) => ({ + type, + title, + url, + notes, + mode, + sourceKind, + privateUrl, + originalFilename, + durationSeconds, + thumbnailUrl, + folder, + remotePlaylistUrl, + remoteRefreshIntervalMinutes, + lastRemoteRefreshAt, + }), + ), +}); + +const downloadPlaylistPayload = (candidatePresets: MediaPreset[], filenamePrefix: string) => { + if (typeof window === 'undefined') { + return; + } + + const blob = new Blob([JSON.stringify(createPlaylistExportPayload(candidatePresets), null, 2)], { type: 'application/json' }); + const objectUrl = window.URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = objectUrl; + anchor.download = `${filenamePrefix}-${new Date().toISOString().slice(0, 10)}.json`; + anchor.click(); + window.URL.revokeObjectURL(objectUrl); +}; + +const getSourceLabel = (sourceKind?: MediaPresetSourceKind) => { + switch (sourceKind) { + case 'uploaded': + return 'uploaded'; + case 'imported': + return 'imported'; + default: + return 'manual'; + } +}; + +const extractPlaylistItemsFromJsonPayload = (payload: unknown) => { + if (Array.isArray(payload)) { + return payload; + } + + if (payload && typeof payload === 'object' && Array.isArray((payload as PlaylistJsonPayload).presets)) { + return (payload as PlaylistJsonPayload).presets ?? []; + } + + return null; +}; + +const parseM3uMetadata = (line: string): M3uMetadata => { + const metadata: M3uMetadata = {}; + + line.replace(/([A-Za-z0-9-]+)="([^"]*)"/g, (_, key: string, value: string) => { + if (key === 'group-title') { + metadata.groupTitle = value.trim(); + } + + if (key === 'tvg-name') { + metadata.tvgName = value.trim(); + } + + return ''; + }); + + const commaIndex = line.indexOf(','); + if (commaIndex >= 0) { + const title = line.slice(commaIndex + 1).trim(); + + if (title) { + metadata.title = title; + } + } + + return metadata; +}; + +const parseM3uPlaylist = (rawText: string) => { + const lines = rawText + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + const parsedPresets: MediaPreset[] = []; + let pendingMetadata: M3uMetadata | null = null; + + lines.forEach((line) => { + if (line.startsWith('#EXTINF')) { + pendingMetadata = parseM3uMetadata(line); + return; + } + + if (line.startsWith('#')) { + return; + } + + if (!isValidUrl(line)) { + pendingMetadata = null; + return; + } + + const type = inferTypeFromSource(line, '', pendingMetadata?.title || pendingMetadata?.tvgName || ''); + const title = pendingMetadata?.title || pendingMetadata?.tvgName || deriveTitleFromUrl(line); + const mode: MediaPresetMode = type === 'radio' ? 'audio' : inferMode(type, line, 'video'); + const notes = [pendingMetadata?.groupTitle ? `Group: ${pendingMetadata.groupTitle}` : '', 'Imported from M3U playlist.'] + .filter(Boolean) + .join(' '); + + parsedPresets.push({ + id: createId(), + type, + title, + url: normalizeTvUrl(line, mode), + notes, + mode, + sourceKind: 'imported', + folder: normalizeFolderName(pendingMetadata?.groupTitle) || undefined, + }); + + pendingMetadata = null; + }); + + return parsedPresets; +}; + +const parseRemotePlaylistPayload = (rawText: string, sourceLabel: string, contentType = '') => { + const trimmedText = rawText.trim(); + const loweredLabel = sourceLabel.toLowerCase(); + const loweredContentType = contentType.toLowerCase(); + + if ( + loweredLabel.endsWith('.json') || + loweredContentType.includes('json') || + trimmedText.startsWith('{') || + trimmedText.startsWith('[') + ) { + const parsedPayload = JSON.parse(rawText) as unknown; + const playlistItems = extractPlaylistItemsFromJsonPayload(parsedPayload); + + if (!playlistItems || playlistItems.length === 0) { + throw new Error('Playlist JSON must contain a non-empty array or a { presets: [...] } object.'); + } + + return playlistItems.map((item, index) => { + if (!item || typeof item !== 'object') { + throw new Error(`Preset ${index + 1} must be a JSON object.`); + } + + const candidate = item as Partial; + const type = candidate.type === 'radio' || candidate.type === 'tv' ? candidate.type : null; + + if (!type) { + throw new Error(`Preset ${index + 1} needs a type of "radio" or "tv".`); + } + + const title = typeof candidate.title === 'string' ? candidate.title.trim() : ''; + const url = typeof candidate.url === 'string' ? candidate.url.trim() : ''; + const notes = typeof candidate.notes === 'string' ? candidate.notes.trim() : ''; + const folder = typeof candidate.folder === 'string' ? normalizeFolderName(candidate.folder) : ''; + const fallbackMode: MediaPresetMode = type === 'radio' ? 'audio' : candidate.mode === 'embed' ? 'embed' : 'video'; + + if (!title) { + throw new Error(`Preset ${index + 1}: Give this preset a short title.`); + } + + if (!url) { + throw new Error(`Preset ${index + 1}: Paste a stream, media, or embed URL.`); + } + + if (!isValidUrl(url)) { + throw new Error(`Preset ${index + 1}: Use a valid http:// or https:// URL.`); + } + + if (type === 'tv' && fallbackMode === 'embed' && !url.includes('embed') && !url.includes('youtube.com/watch?v=') && !url.includes('youtu.be/')) { + throw new Error(`Preset ${index + 1}: Use an embeddable URL or a YouTube watch link for TV embeds.`); + } + + const mode = inferMode(type, url, fallbackMode); + + return { + id: createId(), + type, + title, + url: normalizeTvUrl(url, mode), + notes, + mode, + sourceKind: candidate.sourceKind || 'imported', + privateUrl: typeof candidate.privateUrl === 'string' ? candidate.privateUrl : undefined, + originalFilename: typeof candidate.originalFilename === 'string' ? candidate.originalFilename : undefined, + durationSeconds: typeof candidate.durationSeconds === 'number' ? candidate.durationSeconds : undefined, + thumbnailUrl: typeof candidate.thumbnailUrl === 'string' ? candidate.thumbnailUrl : undefined, + folder: folder || undefined, + remotePlaylistUrl: typeof candidate.remotePlaylistUrl === 'string' ? candidate.remotePlaylistUrl.trim() : undefined, + remoteRefreshIntervalMinutes: + typeof candidate.remoteRefreshIntervalMinutes === 'number' && candidate.remoteRefreshIntervalMinutes > 0 + ? candidate.remoteRefreshIntervalMinutes + : undefined, + lastRemoteRefreshAt: typeof candidate.lastRemoteRefreshAt === 'string' ? candidate.lastRemoteRefreshAt : undefined, + } as MediaPreset; + }); + } + + if ( + loweredLabel.endsWith('.m3u') || + loweredLabel.endsWith('.m3u8') || + loweredContentType.includes('mpegurl') || + trimmedText.startsWith('#EXTM3U') + ) { + const importedPresets = parseM3uPlaylist(rawText); + + if (importedPresets.length === 0) { + throw new Error('The playlist did not contain any valid http:// or https:// stream entries.'); + } + + return importedPresets; + } + + throw new Error('Unsupported playlist format. Upload or fetch JSON, M3U, or M3U8 sources.'); +}; + +const loadMediaMetadata = (preset: MediaPreset) => { + return new Promise>((resolve, reject) => { + if (typeof window === 'undefined') { + resolve({}); + return; + } + + const timeout = window.setTimeout(() => { + cleanup(); + reject(new Error(`Timed out while reading metadata for ${preset.title}.`)); + }, 15000); + + const mediaElement = document.createElement(preset.type === 'radio' ? 'audio' : 'video'); + mediaElement.preload = 'metadata'; + mediaElement.src = preset.url; + + if (preset.type === 'tv') { + const videoElement = mediaElement as HTMLVideoElement; + videoElement.muted = true; + videoElement.playsInline = true; + videoElement.crossOrigin = 'anonymous'; + } + + const cleanup = () => { + window.clearTimeout(timeout); + mediaElement.removeEventListener('loadedmetadata', onLoadedMetadata); + mediaElement.removeEventListener('loadeddata', onLoadedData); + mediaElement.removeEventListener('error', onError); + mediaElement.pause(); + mediaElement.removeAttribute('src'); + mediaElement.load(); + }; + + const resolveMetadata = (thumbnailUrl?: string) => { + const durationSeconds = Number.isFinite(mediaElement.duration) && mediaElement.duration > 0 ? mediaElement.duration : undefined; + cleanup(); + resolve({ + durationSeconds, + thumbnailUrl, + }); + }; + + const onLoadedMetadata = () => { + if (preset.type === 'radio') { + resolveMetadata(); + } + }; + + const onLoadedData = () => { + if (preset.type !== 'tv') { + return; + } + + const videoElement = mediaElement as HTMLVideoElement; + + if (!videoElement.videoWidth || !videoElement.videoHeight) { + resolveMetadata(); + return; + } + + try { + const canvas = document.createElement('canvas'); + const maxWidth = 180; + const scale = Math.min(1, maxWidth / videoElement.videoWidth); + canvas.width = Math.max(1, Math.round(videoElement.videoWidth * scale)); + canvas.height = Math.max(1, Math.round(videoElement.videoHeight * scale)); + const context = canvas.getContext('2d'); + + if (!context) { + resolveMetadata(); + return; + } + + context.drawImage(videoElement, 0, 0, canvas.width, canvas.height); + resolveMetadata(canvas.toDataURL('image/jpeg', 0.72)); + } catch (error) { + console.error('Failed to generate thumbnail for uploaded video:', error); + resolveMetadata(); + } + }; + + const onError = () => { + cleanup(); + reject(new Error(`Could not read metadata for ${preset.title}.`)); + }; + + mediaElement.addEventListener('loadedmetadata', onLoadedMetadata); + mediaElement.addEventListener('loadeddata', onLoadedData); + mediaElement.addEventListener('error', onError); + mediaElement.load(); + }); +}; + export default function InteractionHubPage() { const [presets, setPresets] = useState(starterMediaPresets); - const [selectedPresetId, setSelectedPresetId] = useState(starterMediaPresets[0].id); + const [selectedPresetId, setSelectedPresetId] = useState(starterMediaPresets[0]?.id ?? ''); const [formState, setFormState] = useState(defaultFormState); const [formErrors, setFormErrors] = useState({}); const [feedbackMessage, setFeedbackMessage] = useState(''); + const [feedbackTone, setFeedbackTone] = useState<'success' | 'error'>('success'); + const [isImportingPlaylist, setIsImportingPlaylist] = useState(false); + const [isUploadingMedia, setIsUploadingMedia] = useState(false); + const [isImportingRemotePlaylist, setIsImportingRemotePlaylist] = useState(false); + const [remotePlaylistUrl, setRemotePlaylistUrl] = useState(''); + const [remotePlaylistFolder, setRemotePlaylistFolder] = useState(''); + const [remoteRefreshMinutes, setRemoteRefreshMinutes] = useState('0'); + const [folderFilter, setFolderFilter] = useState('all'); + const [selectedPresetIds, setSelectedPresetIds] = useState([]); + const [isDropZoneActive, setIsDropZoneActive] = useState(false); + const [editingPresetId, setEditingPresetId] = useState(''); + const [editFormState, setEditFormState] = useState(defaultFormState); + const [editErrors, setEditErrors] = useState({}); + const [metadataLoadingIds, setMetadataLoadingIds] = useState([]); + const [remoteSyncState, setRemoteSyncState] = useState>({}); + const playlistInputRef = useRef(null); + const mediaUploadInputRef = useRef(null); + const metadataLoadingIdsRef = useRef>(new Set()); + const remoteSyncInFlightRef = useRef>(new Set()); + + const showFeedback = useCallback((tone: 'success' | 'error', message: string) => { + setFeedbackTone(tone); + setFeedbackMessage(message); + }, []); + + const validateForm = useCallback((values: FormState) => { + const errors: FormErrors = {}; + + if (!values.title.trim()) { + errors.title = 'Give this preset a short title.'; + } + + if (!values.url.trim()) { + errors.url = 'Paste a stream, media, or embed URL.'; + } else if (!isValidUrl(values.url.trim())) { + errors.url = 'Use a valid http:// or https:// URL.'; + } + + if ( + values.type === 'tv' && + values.mode === 'embed' && + !values.url.includes('embed') && + !values.url.includes('youtube.com/watch?v=') && + !values.url.includes('youtu.be/') + ) { + errors.mode = 'Use an embeddable URL or a YouTube watch link for TV embeds.'; + } + + return errors; + }, []); + + const mergeImportedPresets = useCallback( + (incomingPresets: MediaPreset[]) => { + const seen = new Set(presets.map(getPresetFingerprint)); + const acceptedPresets: MediaPreset[] = []; + + incomingPresets.forEach((preset) => { + const fingerprint = getPresetFingerprint(preset); + + if (seen.has(fingerprint)) { + return; + } + + seen.add(fingerprint); + acceptedPresets.push(preset); + }); + + return { + acceptedPresets, + duplicatesSkipped: incomingPresets.length - acceptedPresets.length, + mergedPresets: [...acceptedPresets, ...presets], + }; + }, + [presets], + ); + + const hydratePresetMetadata = useCallback(async (candidatePresets: MediaPreset[]) => { + const uploadedPresets = candidatePresets.filter( + (preset) => + isUploadedPreset(preset) && + (!preset.durationSeconds || (preset.type === 'tv' && preset.mode !== 'embed' && !preset.thumbnailUrl)) && + !metadataLoadingIdsRef.current.has(preset.id), + ); + + if (uploadedPresets.length === 0) { + return; + } + + uploadedPresets.forEach((preset) => metadataLoadingIdsRef.current.add(preset.id)); + setMetadataLoadingIds((current) => Array.from(new Set([...current, ...uploadedPresets.map((preset) => preset.id)]))); + + for (const preset of uploadedPresets) { + try { + const metadata = await loadMediaMetadata(preset); + setPresets((current) => + current.map((item) => { + if (item.id !== preset.id) { + return item; + } + + return { + ...item, + durationSeconds: metadata.durationSeconds ?? item.durationSeconds, + thumbnailUrl: metadata.thumbnailUrl ?? item.thumbnailUrl, + }; + }), + ); + } catch (error) { + console.error('Failed to load uploaded media metadata:', error); + } finally { + metadataLoadingIdsRef.current.delete(preset.id); + setMetadataLoadingIds((current) => current.filter((itemId) => itemId !== preset.id)); + } + } + }, []); useEffect(() => { if (typeof window === 'undefined') { @@ -172,6 +835,10 @@ export default function InteractionHubPage() { window.localStorage.setItem(MEDIA_PRESETS_STORAGE_KEY, JSON.stringify(presets)); }, [presets]); + useEffect(() => { + hydratePresetMetadata(presets); + }, [hydratePresetMetadata, presets]); + const groupedLinks = useMemo(() => { return modularInteractionLinks.reduce>((acc, item) => { const key = item.type; @@ -186,9 +853,91 @@ export default function InteractionHubPage() { }, []); const selectedPreset = presets.find((preset) => preset.id === selectedPresetId) ?? presets[0]; - const radioPresets = presets.filter((preset) => preset.type === 'radio'); const tvPresets = presets.filter((preset) => preset.type === 'tv'); + const folderOptions = useMemo(() => { + const names = Array.from(new Set(presets.map((preset) => getPresetFolderLabel(preset)))); + return names.sort((left, right) => { + if (left === 'Ungrouped') { + return 1; + } + + if (right === 'Ungrouped') { + return -1; + } + + return left.localeCompare(right); + }); + }, [presets]); + const visiblePresets = useMemo(() => { + if (folderFilter === 'all') { + return presets; + } + + return presets.filter((preset) => getPresetFolderLabel(preset) === folderFilter); + }, [folderFilter, presets]); + const groupedVisiblePresets = useMemo(() => { + return visiblePresets.reduce>((acc, preset) => { + const label = getPresetFolderLabel(preset); + const existingGroup = acc.find((group) => group.label === label); + + if (existingGroup) { + existingGroup.presets.push(preset); + return acc; + } + + acc.push({ label, presets: [preset] }); + return acc; + }, []); + }, [visiblePresets]); + const remoteSources = useMemo(() => { + return presets.reduce((acc, preset) => { + if (!preset.remotePlaylistUrl) { + return acc; + } + + const normalizedUrl = preset.remotePlaylistUrl.trim(); + + if (!normalizedUrl) { + return acc; + } + + const existing = acc.find((item) => item.url === normalizedUrl); + const folderLabel = getPresetFolderLabel(preset); + const lastRefreshAt = preset.lastRemoteRefreshAt; + + if (existing) { + existing.presetCount += 1; + + if (!existing.folderLabels.includes(folderLabel)) { + existing.folderLabels.push(folderLabel); + } + + if (preset.remoteRefreshIntervalMinutes) { + existing.refreshMinutes = + typeof existing.refreshMinutes === 'number' + ? Math.min(existing.refreshMinutes, preset.remoteRefreshIntervalMinutes) + : preset.remoteRefreshIntervalMinutes; + } + + if (lastRefreshAt && (!existing.lastRemoteRefreshAt || new Date(lastRefreshAt).getTime() > new Date(existing.lastRemoteRefreshAt).getTime())) { + existing.lastRemoteRefreshAt = lastRefreshAt; + } + + return acc; + } + + acc.push({ + url: normalizedUrl, + presetCount: 1, + folderLabels: [folderLabel], + refreshMinutes: preset.remoteRefreshIntervalMinutes, + lastRemoteRefreshAt: lastRefreshAt, + }); + return acc; + }, []); + }, [presets]); + const allVisibleSelected = visiblePresets.length > 0 && visiblePresets.every((preset) => selectedPresetIds.includes(preset.id)); useEffect(() => { if (!selectedPreset && presets[0]) { @@ -196,40 +945,402 @@ export default function InteractionHubPage() { } }, [presets, selectedPreset]); - const validateForm = (values: FormState) => { - const errors: FormErrors = {}; + useEffect(() => { + setSelectedPresetIds((current) => current.filter((presetId) => presets.some((preset) => preset.id === presetId))); + }, [presets]); - if (!values.title.trim()) { - errors.title = 'Give this preset a short title.'; + useEffect(() => { + if (folderFilter !== 'all' && !folderOptions.includes(folderFilter)) { + setFolderFilter('all'); } - - if (!values.url.trim()) { - errors.url = 'Paste a stream, media, or embed URL.'; - } else if (!isValidUrl(values.url.trim())) { - errors.url = 'Use a valid http:// or https:// URL.'; - } - - if (values.type === 'tv' && values.mode === 'embed' && !values.url.includes('embed') && !values.url.includes('youtube.com/watch?v=')) { - errors.mode = 'Use an embeddable URL or a YouTube watch link for TV embeds.'; - } - - return errors; - }; + }, [folderFilter, folderOptions]); const handleFieldChange = (field: K, value: FormState[K]) => { setFeedbackMessage(''); + setFeedbackTone('success'); setFormErrors((current) => ({ ...current, [field]: undefined })); setFormState((current) => { const nextState = { ...current, [field]: value }; - if (field === 'type' && value === 'radio') { - nextState.mode = 'audio'; + if (field === 'type') { + nextState.mode = value === 'radio' ? 'audio' : 'video'; } return nextState; }); }; + const applyImportedPresets = (incomingPresets: MediaPreset[], sourceLabel: string) => { + const { acceptedPresets, duplicatesSkipped, mergedPresets } = mergeImportedPresets(incomingPresets); + + if (acceptedPresets.length === 0) { + showFeedback('error', 'This playlist only contains presets that are already saved in this browser.'); + return; + } + + setPresets(mergedPresets); + setSelectedPresetId(acceptedPresets[0].id); + showFeedback( + 'success', + duplicatesSkipped > 0 + ? `Imported ${acceptedPresets.length} preset${acceptedPresets.length === 1 ? '' : 's'} from ${sourceLabel} and skipped ${duplicatesSkipped} duplicate${duplicatesSkipped === 1 ? '' : 's'}.` + : `Imported ${acceptedPresets.length} preset${acceptedPresets.length === 1 ? '' : 's'} from ${sourceLabel}.`, + ); + }; + + const processPlaylistFiles = async (playlistFiles: File[]) => { + if (playlistFiles.length === 0) { + return; + } + + setIsImportingPlaylist(true); + + try { + const importedPresets: MediaPreset[] = []; + + for (const file of playlistFiles) { + const rawText = await file.text(); + const normalizedPresets = parseRemotePlaylistPayload(rawText, file.name, file.type).map((preset) => ({ + ...preset, + sourceKind: 'imported' as const, + })); + importedPresets.push(...normalizedPresets); + } + + applyImportedPresets(importedPresets, playlistFiles.length === 1 ? playlistFiles[0].name : 'dropped/uploaded playlists'); + } catch (error) { + console.error('Failed to import media playlist:', error); + showFeedback('error', error instanceof Error ? error.message : 'Failed to import playlist file.'); + } finally { + setIsImportingPlaylist(false); + } + }; + + const processUploadedMediaFiles = async (selectedFiles: File[]) => { + if (selectedFiles.length === 0) { + return; + } + + setIsUploadingMedia(true); + + try { + const uploadedPresets: MediaPreset[] = []; + + for (const file of selectedFiles) { + const inferredType = inferTypeFromSource(file.name, file.type, file.name); + + if ( + !file.type.startsWith('audio/') && + !file.type.startsWith('video/') && + !AUDIO_EXTENSIONS.includes(getFileExtension(file.name)) && + !VIDEO_EXTENSIONS.includes(getFileExtension(file.name)) + ) { + throw new Error(`"${file.name}" must be an audio or video file.`); + } + + const extension = getFileExtension(file.name) || (inferredType === 'radio' ? 'mp3' : 'mp4'); + const uploadId = createId(); + const filename = `${uploadId}.${extension}`; + const privateUrl = `${PUBLIC_MEDIA_PRIVATE_PREFIX}/${filename}`; + const formData = new FormData(); + formData.append('file', file); + formData.append('filename', filename); + + await axios.post(`/file/upload-public/${PUBLIC_MEDIA_UPLOAD_TABLE}/${PUBLIC_MEDIA_UPLOAD_FIELD}`, formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + + const mode: MediaPresetMode = inferredType === 'radio' ? 'audio' : 'video'; + const title = deriveTitleFromFilename(file.name); + + uploadedPresets.push({ + id: uploadId, + type: inferredType, + title, + url: getDownloadUrl(privateUrl), + notes: `Uploaded from this device. Original file: ${file.name}`, + mode, + sourceKind: 'uploaded', + privateUrl, + originalFilename: file.name, + }); + } + + const { acceptedPresets, duplicatesSkipped, mergedPresets } = mergeImportedPresets(uploadedPresets); + + if (acceptedPresets.length === 0) { + showFeedback('error', 'Every uploaded file already exists in the saved preset list.'); + return; + } + + setPresets(mergedPresets); + setSelectedPresetId(acceptedPresets[0].id); + hydratePresetMetadata(acceptedPresets); + showFeedback( + 'success', + duplicatesSkipped > 0 + ? `Uploaded ${acceptedPresets.length} media preset${acceptedPresets.length === 1 ? '' : 's'} and skipped ${duplicatesSkipped} duplicate${duplicatesSkipped === 1 ? '' : 's'}.` + : `Uploaded ${acceptedPresets.length} media preset${acceptedPresets.length === 1 ? '' : 's'} and loaded the first one into preview.`, + ); + } catch (error) { + console.error('Failed to upload media files:', error); + const axiosMessage = axios.isAxiosError(error) ? error.response?.data?.message || error.message : null; + showFeedback('error', axiosMessage || (error instanceof Error ? error.message : 'Failed to upload media files.')); + } finally { + setIsUploadingMedia(false); + } + }; + + const handleImportPlaylist = async (event: React.ChangeEvent) => { + const playlistFiles = Array.from(event.target.files ?? []); + await processPlaylistFiles(playlistFiles); + event.target.value = ''; + }; + + const handleUploadMedia = async (event: React.ChangeEvent) => { + const selectedFiles = Array.from(event.target.files ?? []); + await processUploadedMediaFiles(selectedFiles); + event.target.value = ''; + }; + + const handleExportPlaylist = () => { + downloadPlaylistPayload(presets, 'interaction-hub-playlist'); + showFeedback('success', `Downloaded ${presets.length} saved preset${presets.length === 1 ? '' : 's'} as JSON.`); + }; + + const refreshRemoteSource = useCallback( + async (playlistUrl: string, options: { silent?: boolean } = {}) => { + const normalizedUrl = playlistUrl.trim(); + + if (!normalizedUrl || remoteSyncInFlightRef.current.has(normalizedUrl)) { + return; + } + + remoteSyncInFlightRef.current.add(normalizedUrl); + setRemoteSyncState((current) => ({ + ...current, + [normalizedUrl]: { + ...current[normalizedUrl], + isRefreshing: true, + lastError: undefined, + lastMessage: options.silent ? current[normalizedUrl]?.lastMessage : 'Refreshing playlist...', + }, + })); + + try { + const response = await axios.post('/file/fetch-public-playlist', { + url: normalizedUrl, + }); + const rawText = typeof response.data?.body === 'string' ? response.data.body : ''; + const sourceLabel = response.data?.url || normalizedUrl; + + if (!rawText.trim()) { + throw new Error('The remote playlist response was empty.'); + } + + const parsedPresets = parseRemotePlaylistPayload(rawText, sourceLabel, response.data?.contentType).map((preset) => ({ + ...preset, + sourceKind: 'imported' as const, + })); + const refreshedAt = new Date().toISOString(); + let addedCount = 0; + let totalCount = parsedPresets.length; + + setPresets((current) => { + const currentSourcePresets = current.filter((preset) => preset.remotePlaylistUrl?.trim() === normalizedUrl); + const sourceFolderFallback = normalizeFolderName( + currentSourcePresets.find((preset) => normalizeFolderName(preset.folder))?.folder, + ); + const sourceRefreshMinutes = currentSourcePresets.find((preset) => preset.remoteRefreshIntervalMinutes)?.remoteRefreshIntervalMinutes; + const existingByFingerprint = new Map( + currentSourcePresets.map((preset) => [getPresetFingerprint(preset), preset]), + ); + const refreshedSourcePresets = parsedPresets.map((preset) => { + const fingerprint = getPresetFingerprint(preset); + const existingPreset = existingByFingerprint.get(fingerprint); + const normalizedFolder = normalizeFolderName(preset.folder || existingPreset?.folder || sourceFolderFallback); + + return { + ...preset, + id: existingPreset?.id || preset.id, + folder: normalizedFolder || undefined, + remotePlaylistUrl: normalizedUrl, + remoteRefreshIntervalMinutes: existingPreset?.remoteRefreshIntervalMinutes ?? sourceRefreshMinutes, + lastRemoteRefreshAt: refreshedAt, + }; + }); + const refreshedByFingerprint = new Map( + refreshedSourcePresets.map((preset) => [getPresetFingerprint(preset), preset]), + ); + const nextPresets = current.map((preset) => { + if (preset.remotePlaylistUrl?.trim() !== normalizedUrl) { + return preset; + } + + const refreshedPreset = refreshedByFingerprint.get(getPresetFingerprint(preset)); + + if (!refreshedPreset) { + return { + ...preset, + lastRemoteRefreshAt: refreshedAt, + }; + } + + return { + ...preset, + ...refreshedPreset, + id: preset.id, + }; + }); + const newPresets = refreshedSourcePresets.filter( + (preset) => !existingByFingerprint.has(getPresetFingerprint(preset)), + ); + + addedCount = newPresets.length; + totalCount = refreshedSourcePresets.length; + + return [...newPresets, ...nextPresets]; + }); + + setRemoteSyncState((current) => ({ + ...current, + [normalizedUrl]: { + isRefreshing: false, + lastError: undefined, + lastMessage: + addedCount > 0 + ? `Added ${addedCount} new preset${addedCount === 1 ? '' : 's'} on refresh.` + : `Checked ${totalCount} preset${totalCount === 1 ? '' : 's'} with no new additions.`, + }, + })); + + if (!options.silent) { + showFeedback( + 'success', + addedCount > 0 + ? `Refreshed remote playlist and added ${addedCount} new preset${addedCount === 1 ? '' : 's'}.` + : 'Remote playlist checked successfully. No new presets were added.', + ); + } + } catch (error) { + console.error('Failed to refresh remote playlist source:', { url: normalizedUrl, error }); + const axiosMessage = axios.isAxiosError(error) ? error.response?.data?.message || error.message : null; + const errorMessage = axiosMessage || (error instanceof Error ? error.message : 'Failed to refresh remote playlist.'); + + setRemoteSyncState((current) => ({ + ...current, + [normalizedUrl]: { + ...current[normalizedUrl], + isRefreshing: false, + lastError: errorMessage, + }, + })); + + if (!options.silent) { + showFeedback('error', errorMessage); + } + } finally { + remoteSyncInFlightRef.current.delete(normalizedUrl); + } + }, + [showFeedback], + ); + + useEffect(() => { + if (typeof window === 'undefined' || remoteSources.length === 0) { + return; + } + + const tick = () => { + const now = Date.now(); + + remoteSources.forEach((source) => { + if (!source.refreshMinutes || source.refreshMinutes <= 0) { + return; + } + + if (remoteSyncInFlightRef.current.has(source.url)) { + return; + } + + const lastRefreshAt = source.lastRemoteRefreshAt ? new Date(source.lastRemoteRefreshAt).getTime() : 0; + + if (!lastRefreshAt || now - lastRefreshAt >= source.refreshMinutes * 60 * 1000) { + refreshRemoteSource(source.url, { silent: true }); + } + }); + }; + + tick(); + const intervalId = window.setInterval(tick, REMOTE_REFRESH_POLL_INTERVAL_MS); + + return () => { + window.clearInterval(intervalId); + }; + }, [refreshRemoteSource, remoteSources]); + + const handleImportRemotePlaylist = async () => { + const playlistUrl = remotePlaylistUrl.trim(); + const normalizedFolder = normalizeFolderName(remotePlaylistFolder); + const refreshMinutesValue = Number(remoteRefreshMinutes); + const remoteRefreshIntervalMinutes = Number.isFinite(refreshMinutesValue) && refreshMinutesValue > 0 ? refreshMinutesValue : undefined; + + if (!playlistUrl) { + showFeedback('error', 'Paste a playlist URL first.'); + return; + } + + if (!isValidUrl(playlistUrl)) { + showFeedback('error', 'Use a valid http:// or https:// playlist URL.'); + return; + } + + setIsImportingRemotePlaylist(true); + + try { + const response = await axios.post('/file/fetch-public-playlist', { + url: playlistUrl, + }); + const rawText = typeof response.data?.body === 'string' ? response.data.body : ''; + const sourceLabel = response.data?.url || playlistUrl; + + if (!rawText.trim()) { + throw new Error('The remote playlist response was empty.'); + } + + const importedAt = new Date().toISOString(); + const importedPresets = parseRemotePlaylistPayload(rawText, sourceLabel, response.data?.contentType).map((preset) => ({ + ...preset, + sourceKind: 'imported' as const, + folder: normalizeFolderName(preset.folder || normalizedFolder) || undefined, + remotePlaylistUrl: playlistUrl, + remoteRefreshIntervalMinutes, + lastRemoteRefreshAt: importedAt, + })); + + applyImportedPresets(importedPresets, sourceLabel); + setRemoteSyncState((current) => ({ + ...current, + [playlistUrl]: { + isRefreshing: false, + lastError: undefined, + lastMessage: remoteRefreshIntervalMinutes + ? `Imported with auto-refresh set to ${formatRefreshInterval(remoteRefreshIntervalMinutes).toLowerCase()}.` + : 'Imported with manual refresh controls enabled.', + }, + })); + setRemotePlaylistUrl(''); + } catch (error) { + console.error('Failed to import remote playlist:', error); + const axiosMessage = axios.isAxiosError(error) ? error.response?.data?.message || error.message : null; + showFeedback('error', axiosMessage || (error instanceof Error ? error.message : 'Failed to import the remote playlist URL.')); + } finally { + setIsImportingRemotePlaylist(false); + } + }; + const handleSubmit = (event: React.FormEvent) => { event.preventDefault(); @@ -240,6 +1351,7 @@ export default function InteractionHubPage() { } const nextMode = inferMode(formState.type, formState.url.trim(), formState.mode); + const normalizedFolder = normalizeFolderName(formState.folder); const nextPreset: MediaPreset = { id: createId(), type: formState.type, @@ -247,30 +1359,236 @@ export default function InteractionHubPage() { url: normalizeTvUrl(formState.url.trim(), nextMode), notes: formState.notes.trim(), mode: nextMode, + folder: normalizedFolder || undefined, + sourceKind: 'manual', }; const nextPresets = [nextPreset, ...presets]; setPresets(nextPresets); setSelectedPresetId(nextPreset.id); - setFeedbackMessage(`${nextPreset.title} is now saved in this browser and loaded into the preview.`); + showFeedback('success', `${nextPreset.title} is now saved in this browser and loaded into the preview.`); setFormErrors({}); setFormState({ ...defaultFormState, type: formState.type, mode: formState.type === 'radio' ? 'audio' : 'video', + folder: formState.folder, + }); + }; + + const beginEditingPreset = (preset: MediaPreset) => { + setEditingPresetId(preset.id); + setEditErrors({}); + setEditFormState({ + type: preset.type, + title: preset.title, + url: preset.url, + notes: preset.notes, + mode: preset.mode, + folder: preset.folder || '', + }); + }; + + const handleEditFieldChange = (field: K, value: FormState[K]) => { + setEditErrors((current) => ({ ...current, [field]: undefined })); + setEditFormState((current) => ({ ...current, [field]: value })); + }; + + const handleSaveEdit = (preset: MediaPreset) => { + const errors = validateForm(editFormState); + + if (Object.keys(errors).length > 0) { + setEditErrors(errors); + return; + } + + const nextMode = inferMode(editFormState.type, editFormState.url.trim(), editFormState.mode); + const nextPresetFingerprint = getPresetFingerprint({ + type: editFormState.type, + title: editFormState.title.trim(), + url: normalizeTvUrl(editFormState.url.trim(), nextMode), + }); + + const duplicateExists = presets.some( + (candidate) => candidate.id !== preset.id && getPresetFingerprint(candidate) === nextPresetFingerprint, + ); + + if (duplicateExists) { + showFeedback('error', 'Another saved preset already uses the same type, title, and URL.'); + return; + } + + const urlChanged = preset.url !== editFormState.url.trim(); + const modeChanged = preset.mode !== nextMode; + const normalizedFolder = normalizeFolderName(editFormState.folder); + + const updatedPreset: MediaPreset = { + ...preset, + title: editFormState.title.trim(), + url: normalizeTvUrl(editFormState.url.trim(), nextMode), + notes: editFormState.notes.trim(), + mode: nextMode, + folder: normalizedFolder || undefined, + durationSeconds: urlChanged ? undefined : preset.durationSeconds, + thumbnailUrl: urlChanged || modeChanged ? undefined : preset.thumbnailUrl, + }; + + setPresets((current) => current.map((item) => (item.id === preset.id ? updatedPreset : item))); + setEditingPresetId(''); + setEditErrors({}); + if (selectedPresetId === preset.id) { + setSelectedPresetId(updatedPreset.id); + } + if (isUploadedPreset(updatedPreset)) { + hydratePresetMetadata([updatedPreset]); + } + showFeedback('success', `${updatedPreset.title} has been updated.`); + }; + + const handleTogglePresetSelection = (presetId: string) => { + setSelectedPresetIds((current) => + current.includes(presetId) ? current.filter((item) => item !== presetId) : [...current, presetId], + ); + }; + + const handleToggleVisibleSelection = () => { + const visibleIds = visiblePresets.map((preset) => preset.id); + + setSelectedPresetIds((current) => { + if (allVisibleSelected) { + return current.filter((presetId) => !visibleIds.includes(presetId)); + } + + return Array.from(new Set([...current, ...visibleIds])); + }); + }; + + const handleClearSelection = () => { + setSelectedPresetIds([]); + }; + + const handleMovePreset = (presetId: string, direction: 'up' | 'down') => { + setPresets((current) => { + const sourceIndex = current.findIndex((preset) => preset.id === presetId); + + if (sourceIndex < 0) { + return current; + } + + const folderLabel = getPresetFolderLabel(current[sourceIndex]); + const folderIndexes = current.reduce((acc, preset, index) => { + if (getPresetFolderLabel(preset) === folderLabel) { + acc.push(index); + } + + return acc; + }, []); + const position = folderIndexes.indexOf(sourceIndex); + const targetIndex = direction === 'up' ? folderIndexes[position - 1] : folderIndexes[position + 1]; + + if (typeof targetIndex !== 'number') { + return current; + } + + const nextPresets = [...current]; + const temp = nextPresets[sourceIndex]; + nextPresets[sourceIndex] = nextPresets[targetIndex]; + nextPresets[targetIndex] = temp; + return nextPresets; }); }; const handleDeletePreset = (presetId: string) => { const nextPresets = presets.filter((preset) => preset.id !== presetId); setPresets(nextPresets.length > 0 ? nextPresets : []); - setFeedbackMessage('Preset removed from this browser.'); + setSelectedPresetIds((current) => current.filter((item) => item !== presetId)); + showFeedback('success', 'Preset removed from this browser.'); + + if (editingPresetId === presetId) { + setEditingPresetId(''); + setEditErrors({}); + } if (selectedPresetId === presetId) { setSelectedPresetId(nextPresets[0]?.id ?? ''); } }; + const handleDeleteSelectedPresets = () => { + if (selectedPresetIds.length === 0) { + showFeedback('error', 'Select at least one preset first.'); + return; + } + + const selectedIdSet = new Set(selectedPresetIds); + const nextPresets = presets.filter((preset) => !selectedIdSet.has(preset.id)); + + setPresets(nextPresets); + setSelectedPresetIds([]); + + if (editingPresetId && selectedIdSet.has(editingPresetId)) { + setEditingPresetId(''); + setEditErrors({}); + } + + if (selectedPresetId && selectedIdSet.has(selectedPresetId)) { + setSelectedPresetId(nextPresets[0]?.id ?? ''); + } + + showFeedback('success', `Removed ${selectedIdSet.size} selected preset${selectedIdSet.size === 1 ? '' : 's'}.`); + }; + + const handleExportSelectedPresets = () => { + const selectedPresetSet = new Set(selectedPresetIds); + const selectedPresets = presets.filter((preset) => selectedPresetSet.has(preset.id)); + + if (selectedPresets.length === 0) { + showFeedback('error', 'Select at least one preset before exporting.'); + return; + } + + downloadPlaylistPayload(selectedPresets, 'interaction-hub-selected-presets'); + showFeedback('success', `Downloaded ${selectedPresets.length} selected preset${selectedPresets.length === 1 ? '' : 's'} as JSON.`); + }; + + const handleDropFiles = async (files: File[]) => { + if (files.length === 0) { + return; + } + + const playlistFiles = files.filter((file) => isPlaylistFile(file)); + const mediaFiles = files.filter((file) => !isPlaylistFile(file)); + + if (playlistFiles.length === 0 && mediaFiles.length === 0) { + showFeedback('error', 'Drop JSON, M3U, M3U8, audio, or video files.'); + return; + } + + if (playlistFiles.length > 0) { + await processPlaylistFiles(playlistFiles); + } + + if (mediaFiles.length > 0) { + await processUploadedMediaFiles(mediaFiles); + } + }; + + const handleDropZoneDragOver = (event: React.DragEvent) => { + event.preventDefault(); + setIsDropZoneActive(true); + }; + + const handleDropZoneDragLeave = (event: React.DragEvent) => { + event.preventDefault(); + setIsDropZoneActive(false); + }; + + const handleDropZoneDrop = async (event: React.DragEvent) => { + event.preventDefault(); + setIsDropZoneActive(false); + await handleDropFiles(Array.from(event.dataTransfer.files ?? [])); + }; + return ( <> @@ -298,7 +1616,7 @@ export default function InteractionHubPage() {
- +
@@ -368,7 +1686,7 @@ export default function InteractionHubPage() {

Preset studio

-

Save a radio or TV source locally and push it straight into the preview.

+

Save, import, export, upload, edit, and preview radio or TV sources directly inside this public deck.

@@ -416,17 +1734,24 @@ export default function InteractionHubPage() { handleFieldChange('url', event.target.value)} - placeholder={ - formState.type === 'radio' - ? 'https://example.com/live.mp3' - : 'https://example.com/embed/live or https://example.com/video.mp4' - } + placeholder={formState.type === 'radio' ? 'https://example.com/live.mp3' : 'https://example.com/embed/live or https://example.com/video.mp4'} className="w-full rounded-2xl border border-white/10 bg-slate-900 px-4 py-3 text-white outline-none transition focus:border-cyan-300/40" /> {formErrors.url ?

{formErrors.url}

: null} +
+ + handleFieldChange('folder', event.target.value)} + placeholder="News feeds, Community radio, Featured TV" + className="w-full rounded-2xl border border-white/10 bg-slate-900 px-4 py-3 text-white outline-none transition focus:border-cyan-300/40" + /> +

Optional. Use this to group presets into folders in the saved library.

+
+ {formState.type === 'tv' ? (
@@ -462,18 +1787,176 @@ export default function InteractionHubPage() {