diff --git a/backend/src/index.js b/backend/src/index.js index 9f8d396..cec6e74 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -44,6 +44,7 @@ const commentsRoutes = require('./routes/comments'); const favoritesRoutes = require('./routes/favorites'); const search_logsRoutes = require('./routes/search_logs'); +const publicMediaRoutes = require('./routes/public-media'); const getBaseUrl = (url) => { @@ -100,6 +101,7 @@ app.use(bodyParser.json()); app.use('/api/auth', authRoutes); app.use('/api/file', fileRoutes); app.use('/api/pexels', pexelsRoutes); +app.use('/api/public-media', publicMediaRoutes); app.enable('trust proxy'); diff --git a/backend/src/routes/public-media.js b/backend/src/routes/public-media.js new file mode 100644 index 0000000..fe9248a --- /dev/null +++ b/backend/src/routes/public-media.js @@ -0,0 +1,582 @@ +const express = require('express'); +const { Op } = require('sequelize'); + +const db = require('../db/models'); +const wrapAsync = require('../helpers').wrapAsync; + +const router = express.Router(); + +const showInclude = [ + { model: db.categories, as: 'category', required: false }, + { model: db.users, as: 'owner', required: false }, + { model: db.file, as: 'poster_image', required: false }, + { model: db.file, as: 'banner_image', required: false }, +]; + +const episodeInclude = [ + { + model: db.shows, + as: 'show', + required: false, + include: [{ model: db.categories, as: 'category', required: false }], + }, + { model: db.users, as: 'uploader', required: false }, + { model: db.file, as: 'thumbnail_image', required: false }, + { + model: db.media_assets, + as: 'media_assets_episode', + required: false, + include: [ + { model: db.file, as: 'file', required: false }, + { model: db.file, as: 'preview_image', required: false }, + ], + }, +]; + +const streamInclude = [ + { model: db.categories, as: 'category', required: false }, + { model: db.users, as: 'host', required: false }, + { model: db.file, as: 'cover_image', required: false }, +]; + +const categoryInclude = [{ model: db.file, as: 'cover_image', required: false }]; + +function parsePositiveInt(value, fallback) { + const parsed = Number.parseInt(String(value || ''), 10); + + if (Number.isNaN(parsed) || parsed < 0) { + return fallback; + } + + return parsed; +} + +function fileUrl(fileRecord) { + if (!fileRecord) return ''; + return fileRecord.publicUrl || fileRecord.privateUrl || ''; +} + +function primaryFile(files) { + if (!Array.isArray(files) || !files.length) return ''; + return fileUrl(files[0]); +} + +function personName(user) { + if (!user) return ''; + return [user.firstName, user.lastName].filter(Boolean).join(' ').trim() || user.email || ''; +} + +function serializeCategory(item) { + if (!item) return null; + + return { + id: item.id, + name: item.name, + slug: item.slug, + description: item.description, + sort_order: item.sort_order, + is_featured: item.is_featured, + is_active: item.is_active, + imageUrl: primaryFile(item.cover_image), + }; +} + +function serializeMediaAsset(item) { + if (!item) return null; + + return { + id: item.id, + asset_type: item.asset_type, + title: item.title, + source_url: item.source_url, + delivery: item.delivery, + mime_type: item.mime_type, + file_size_bytes: item.file_size_bytes, + bitrate_kbps: item.bitrate_kbps, + resolution: item.resolution, + duration_seconds: item.duration_seconds, + is_primary: item.is_primary, + fileUrl: primaryFile(item.file), + previewImageUrl: primaryFile(item.preview_image), + }; +} + +function pickPrimaryAsset(mediaAssets) { + if (!Array.isArray(mediaAssets) || !mediaAssets.length) return null; + return mediaAssets.find((item) => item.is_primary) || mediaAssets[0] || null; +} + +function serializeShow(item) { + if (!item) return null; + + return { + id: item.id, + title: item.title, + slug: item.slug, + summary: item.summary, + show_type: item.show_type, + status: item.status, + is_featured: item.is_featured, + release_year: item.release_year, + posterImageUrl: primaryFile(item.poster_image), + bannerImageUrl: primaryFile(item.banner_image), + category: serializeCategory(item.category), + owner: item.owner + ? { + id: item.owner.id, + name: personName(item.owner), + email: item.owner.email, + } + : null, + }; +} + +function serializeEpisode(item, options = {}) { + if (!item) return null; + + const mediaAssets = Array.isArray(item.media_assets_episode) + ? item.media_assets_episode.map((asset) => serializeMediaAsset(asset)).filter(Boolean) + : []; + const primaryAsset = pickPrimaryAsset(mediaAssets); + + const output = { + id: item.id, + title: item.title, + slug: item.slug, + description: item.description, + status: item.status, + published_at: item.published_at, + scheduled_at: item.scheduled_at, + season_number: item.season_number, + episode_number: item.episode_number, + duration_seconds: item.duration_seconds, + rating_average: item.rating_average, + views_count: item.views_count, + is_featured: item.is_featured, + thumbnailImageUrl: primaryFile(item.thumbnail_image), + show: item.show ? serializeShow(item.show) : null, + uploader: item.uploader + ? { + id: item.uploader.id, + name: personName(item.uploader), + email: item.uploader.email, + } + : null, + primaryMediaAsset: primaryAsset, + }; + + if (options.includeMediaAssets) { + output.media_assets = mediaAssets; + } + + return output; +} + +const demoStreamSources = { + audioDefault: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3', + audioMusic: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-2.mp3', + audioTalk: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-3.mp3', + videoDefault: 'https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4', +}; + +function resolvePublicStreamSource(item) { + const originalUrl = item?.stream_url || ''; + + if (!originalUrl) { + return { + stream_url: '', + original_stream_url: '', + is_demo_stream: false, + playback_note: '', + }; + } + + if (!originalUrl.includes('stream.aliyomomotmedia.com')) { + return { + stream_url: originalUrl, + original_stream_url: originalUrl, + is_demo_stream: false, + playback_note: '', + }; + } + + const title = String(item?.title || '').toLowerCase(); + let streamUrl = demoStreamSources.audioDefault; + + if (item?.stream_type === 'video') { + streamUrl = demoStreamSources.videoDefault; + } else if (title.includes('music')) { + streamUrl = demoStreamSources.audioMusic; + } else if (title.includes('culture') || title.includes('call') || title.includes('bulletin')) { + streamUrl = demoStreamSources.audioTalk; + } + + return { + stream_url: streamUrl, + original_stream_url: originalUrl, + is_demo_stream: true, + playback_note: 'Demo playback is active until a production stream source is configured.', + }; +} + +function serializeLiveStream(item) { + if (!item) return null; + + const playback = resolvePublicStreamSource(item); + + return { + id: item.id, + title: item.title, + stream_url: playback.stream_url, + original_stream_url: playback.original_stream_url, + stream_type: item.stream_type, + status: item.status, + starts_at: item.starts_at, + ends_at: item.ends_at, + description: item.description, + is_featured: item.is_featured, + is_demo_stream: playback.is_demo_stream, + playback_note: playback.playback_note, + coverImageUrl: primaryFile(item.cover_image), + category: serializeCategory(item.category), + host: item.host + ? { + id: item.host.id, + name: personName(item.host), + email: item.host.email, + } + : null, + }; +} + +function showWhere(query) { + const where = { status: 'published' }; + + if (query.q) { + where[Op.or] = [ + { title: { [Op.iLike]: `%${query.q}%` } }, + { summary: { [Op.iLike]: `%${query.q}%` } }, + { slug: { [Op.iLike]: `%${query.q}%` } }, + ]; + } + + if (query.categoryId) { + where.categoryId = query.categoryId; + } + + return where; +} + +function episodeWhere(query) { + const where = { status: 'published' }; + + if (query.q) { + where[Op.or] = [ + { title: { [Op.iLike]: `%${query.q}%` } }, + { description: { [Op.iLike]: `%${query.q}%` } }, + { slug: { [Op.iLike]: `%${query.q}%` } }, + ]; + } + + if (query.showId) { + where.showId = query.showId; + } + + return where; +} + +function streamWhere(query) { + const includeOfflineDemo = String(query.includeOfflineDemo || '').toLowerCase() === 'true'; + + const where = { + status: { + [Op.in]: includeOfflineDemo ? ['live', 'scheduled', 'offline'] : ['live', 'scheduled'], + }, + }; + + if (query.q) { + where[Op.or] = [ + { title: { [Op.iLike]: `%${query.q}%` } }, + { description: { [Op.iLike]: `%${query.q}%` } }, + ]; + } + + if (query.categoryId) { + where.categoryId = query.categoryId; + } + + return where; +} + +function sortSerializedStreams(items) { + return [...items].sort((left, right) => { + const leftStatusPriority = left.status === 'live' ? 0 : 1; + const rightStatusPriority = right.status === 'live' ? 0 : 1; + + if (leftStatusPriority !== rightStatusPriority) { + return leftStatusPriority - rightStatusPriority; + } + + const leftTypePriority = left.stream_type === 'video' ? 0 : 1; + const rightTypePriority = right.stream_type === 'video' ? 0 : 1; + + if (leftTypePriority !== rightTypePriority) { + return leftTypePriority - rightTypePriority; + } + + const leftFeaturedPriority = left.is_featured ? 0 : 1; + const rightFeaturedPriority = right.is_featured ? 0 : 1; + + if (leftFeaturedPriority !== rightFeaturedPriority) { + return leftFeaturedPriority - rightFeaturedPriority; + } + + return new Date(left.starts_at || 0).getTime() - new Date(right.starts_at || 0).getTime(); + }); +} + +router.get( + '/overview', + wrapAsync(async (req, res) => { + const categoryLimit = parsePositiveInt(req.query.categoriesLimit, 8); + const showLimit = parsePositiveInt(req.query.showsLimit, 6); + const episodeLimit = parsePositiveInt(req.query.episodesLimit, 8); + const streamLimit = parsePositiveInt(req.query.streamsLimit, 4); + + const [categories, shows, episodes, liveStreams] = await Promise.all([ + db.categories.findAll({ + where: { is_active: true }, + include: categoryInclude, + order: [ + ['is_featured', 'DESC'], + ['sort_order', 'ASC'], + ['name', 'ASC'], + ], + limit: categoryLimit, + }), + db.shows.findAll({ + where: { status: 'published' }, + include: showInclude, + order: [ + ['is_featured', 'DESC'], + ['release_year', 'DESC'], + ['updatedAt', 'DESC'], + ], + limit: showLimit, + distinct: true, + }), + db.episodes.findAll({ + where: { status: 'published' }, + include: episodeInclude, + order: [ + ['is_featured', 'DESC'], + ['published_at', 'DESC'], + ['createdAt', 'DESC'], + ], + limit: episodeLimit, + distinct: true, + }), + db.live_streams.findAll({ + where: streamWhere(req.query), + include: streamInclude, + order: [ + ['is_featured', 'DESC'], + ['starts_at', 'ASC'], + ['updatedAt', 'DESC'], + ], + limit: streamLimit, + distinct: true, + }), + ]); + + const serializedStreams = sortSerializedStreams(liveStreams.map((item) => serializeLiveStream(item))); + + res.status(200).send({ + categories: categories.map((item) => serializeCategory(item)), + shows: shows.map((item) => serializeShow(item)), + episodes: episodes.map((item) => serializeEpisode(item)), + liveStreams: serializedStreams, + }); + }), +); + +router.get( + '/categories', + wrapAsync(async (req, res) => { + const limit = parsePositiveInt(req.query.limit, 24); + + const categories = await db.categories.findAll({ + where: { is_active: true }, + include: categoryInclude, + order: [ + ['is_featured', 'DESC'], + ['sort_order', 'ASC'], + ['name', 'ASC'], + ], + limit, + }); + + res.status(200).send({ + rows: categories.map((item) => serializeCategory(item)), + count: categories.length, + }); + }), +); + +router.get( + '/shows', + wrapAsync(async (req, res) => { + const limit = parsePositiveInt(req.query.limit, 12); + const page = parsePositiveInt(req.query.page, 0); + + const query = { + where: showWhere(req.query), + include: showInclude, + order: [ + ['is_featured', 'DESC'], + ['release_year', 'DESC'], + ['updatedAt', 'DESC'], + ], + limit, + offset: page * limit, + distinct: true, + }; + + const { rows, count } = await db.shows.findAndCountAll(query); + + res.status(200).send({ + rows: rows.map((item) => serializeShow(item)), + count, + page, + limit, + }); + }), +); + +router.get( + '/shows/:id', + wrapAsync(async (req, res) => { + const show = await db.shows.findOne({ + where: { + id: req.params.id, + status: 'published', + }, + include: showInclude, + }); + + if (!show) { + return res.status(404).send({ message: 'Show not found' }); + } + + const episodes = await db.episodes.findAll({ + where: { + showId: show.id, + status: 'published', + }, + include: episodeInclude, + order: [ + ['season_number', 'ASC'], + ['episode_number', 'ASC'], + ['published_at', 'DESC'], + ['createdAt', 'DESC'], + ], + distinct: true, + }); + + return res.status(200).send({ + ...serializeShow(show), + episodes: episodes.map((item) => serializeEpisode(item)), + }); + }), +); + +router.get( + '/episodes', + wrapAsync(async (req, res) => { + const limit = parsePositiveInt(req.query.limit, 16); + const page = parsePositiveInt(req.query.page, 0); + const include = [...episodeInclude]; + + if (req.query.categoryId) { + include[0] = { + ...include[0], + required: true, + where: { categoryId: req.query.categoryId }, + }; + } + + const { rows, count } = await db.episodes.findAndCountAll({ + where: episodeWhere(req.query), + include, + order: [ + ['is_featured', 'DESC'], + ['published_at', 'DESC'], + ['createdAt', 'DESC'], + ], + limit, + offset: page * limit, + distinct: true, + }); + + res.status(200).send({ + rows: rows.map((item) => serializeEpisode(item)), + count, + page, + limit, + }); + }), +); + +router.get( + '/episodes/:id', + wrapAsync(async (req, res) => { + const episode = await db.episodes.findOne({ + where: { + id: req.params.id, + status: 'published', + }, + include: episodeInclude, + distinct: true, + }); + + if (!episode) { + return res.status(404).send({ message: 'Episode not found' }); + } + + return res.status(200).send(serializeEpisode(episode, { includeMediaAssets: true })); + }), +); + +router.get( + '/streams', + wrapAsync(async (req, res) => { + const limit = parsePositiveInt(req.query.limit, 12); + const page = parsePositiveInt(req.query.page, 0); + + const { rows, count } = await db.live_streams.findAndCountAll({ + where: streamWhere(req.query), + include: streamInclude, + order: [ + ['is_featured', 'DESC'], + ['starts_at', 'ASC'], + ['updatedAt', 'DESC'], + ], + limit, + offset: page * limit, + distinct: true, + }); + + const serializedRows = sortSerializedStreams(rows.map((item) => serializeLiveStream(item))); + + res.status(200).send({ + rows: serializedRows, + count, + page, + limit, + }); + }), +); + +router.use('/', require('../helpers').commonErrorHandler); + +module.exports = router; diff --git a/frontend/src/components/NavBarItem.tsx b/frontend/src/components/NavBarItem.tsx index 72935e6..fcbd9b9 100644 --- a/frontend/src/components/NavBarItem.tsx +++ b/frontend/src/components/NavBarItem.tsx @@ -1,6 +1,5 @@ -import React, {useEffect, useRef} from 'react' +import React, { useEffect, useRef, useState } from 'react' import Link from 'next/link' -import { useState } from 'react' import { mdiChevronUp, mdiChevronDown } from '@mdi/js' import BaseDivider from './BaseDivider' import BaseIcon from './BaseIcon' diff --git a/frontend/src/components/PublicMediaPlayer.tsx b/frontend/src/components/PublicMediaPlayer.tsx new file mode 100644 index 0000000..14163e9 --- /dev/null +++ b/frontend/src/components/PublicMediaPlayer.tsx @@ -0,0 +1,289 @@ +import { mdiPlayCircleOutline, mdiRadio } from '@mdi/js'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; + +import { isDirectMedia, isHlsUrl, toYouTubeEmbed } from '../helpers/publicMedia'; +import BaseIcon from './BaseIcon'; + +declare global { + interface Window { + Hls?: any; + } +} + +type PublicMediaPlayerProps = { + title: string; + url?: string; + fallbackUrl?: string; + type?: string; + posterUrl?: string; + emptyMessage?: string; + externalMessage?: string; + externalLabel?: string; +}; + +const HLS_SCRIPT_ID = 'public-media-hls-script'; +const HLS_SCRIPT_SRC = 'https://cdn.jsdelivr.net/npm/hls.js@1.5.18/dist/hls.min.js'; + +function loadHlsLibrary() { + return new Promise((resolve, reject) => { + if (typeof window === 'undefined') { + reject(new Error('HLS can only be loaded in the browser.')); + return; + } + + if (window.Hls) { + resolve(window.Hls); + return; + } + + const existingScript = document.getElementById(HLS_SCRIPT_ID) as HTMLScriptElement | null; + + if (existingScript) { + if (existingScript.dataset.loaded === 'true' && window.Hls) { + resolve(window.Hls); + return; + } + + existingScript.addEventListener('load', () => resolve(window.Hls)); + existingScript.addEventListener('error', () => reject(new Error('Failed to load HLS library.'))); + return; + } + + const script = document.createElement('script'); + script.id = HLS_SCRIPT_ID; + script.src = HLS_SCRIPT_SRC; + script.async = true; + script.onload = () => { + script.dataset.loaded = 'true'; + resolve(window.Hls); + }; + script.onerror = () => reject(new Error('Failed to load HLS library.')); + document.body.appendChild(script); + }); +} + +export default function PublicMediaPlayer({ + title, + url = '', + fallbackUrl = '', + type = 'video', + posterUrl = '', + emptyMessage = 'No public playback source is attached yet.', + externalMessage = 'This stream uses an external playback URL. Open it in a new tab.', + externalLabel = 'Open source', +}: PublicMediaPlayerProps) { + const mediaRef = useRef(null); + const [hlsState, setHlsState] = useState<'idle' | 'loading' | 'ready' | 'fallback' | 'unsupported' | 'error'>('idle'); + const [hlsErrorMessage, setHlsErrorMessage] = useState(''); + + const embedUrl = useMemo(() => toYouTubeEmbed(url || fallbackUrl), [fallbackUrl, url]); + const hlsUrl = useMemo(() => { + if (isHlsUrl(url)) return url; + if (isHlsUrl(fallbackUrl)) return fallbackUrl; + return ''; + }, [fallbackUrl, url]); + const directUrl = useMemo(() => { + if (url && !isHlsUrl(url) && isDirectMedia(url, type)) return url; + if (fallbackUrl && !isHlsUrl(fallbackUrl) && isDirectMedia(fallbackUrl, type)) return fallbackUrl; + return ''; + }, [fallbackUrl, type, url]); + const externalUrl = url || fallbackUrl || ''; + + useEffect(() => { + const mediaElement = mediaRef.current; + + if (!mediaElement || !hlsUrl || embedUrl) { + setHlsState('idle'); + setHlsErrorMessage(''); + return undefined; + } + + let isCancelled = false; + let hlsInstance: any = null; + + const applyFallbackUrl = (message = '') => { + if (!mediaRef.current || !directUrl) { + return false; + } + + mediaRef.current.src = directUrl; + setHlsState('fallback'); + setHlsErrorMessage(message); + return true; + }; + + const attachHls = async () => { + setHlsState('loading'); + setHlsErrorMessage(''); + + const canPlayHlsNatively = + typeof mediaElement.canPlayType === 'function' && + (mediaElement.canPlayType('application/vnd.apple.mpegurl') !== '' || mediaElement.canPlayType('application/x-mpegURL') !== ''); + + if (canPlayHlsNatively) { + mediaElement.src = hlsUrl; + setHlsState('ready'); + return; + } + + if (type !== 'video') { + if (!applyFallbackUrl('Native HLS is unavailable in this browser, so direct audio playback is being used instead.')) { + setHlsState('unsupported'); + } + return; + } + + try { + const Hls = await loadHlsLibrary(); + + if (isCancelled || !mediaRef.current) { + return; + } + + if (!Hls?.isSupported?.()) { + if (!applyFallbackUrl('HLS playback is not supported here, so the direct fallback source is being used instead.')) { + setHlsState('unsupported'); + } + return; + } + + hlsInstance = new Hls({ + enableWorker: true, + lowLatencyMode: true, + }); + + hlsInstance.loadSource(hlsUrl); + hlsInstance.attachMedia(mediaRef.current); + hlsInstance.on(Hls.Events.MANIFEST_PARSED, () => { + if (!isCancelled) { + setHlsState('ready'); + } + }); + hlsInstance.on(Hls.Events.ERROR, (_event: any, data: any) => { + if (!data?.fatal || isCancelled) { + return; + } + + console.error('Public HLS playback failed:', data); + + if (applyFallbackUrl('The HLS stream could not be loaded, so the direct fallback source is being used instead.')) { + hlsInstance.destroy(); + hlsInstance = null; + return; + } + + setHlsState('error'); + setHlsErrorMessage('The live HLS stream could not be loaded in this browser.'); + hlsInstance.destroy(); + hlsInstance = null; + }); + } catch (error) { + console.error('Failed to initialize HLS playback:', error); + if (!applyFallbackUrl('The browser player could not initialize HLS, so the direct fallback source is being used instead.')) { + setHlsState('error'); + setHlsErrorMessage('The browser player could not initialize HLS playback.'); + } + } + }; + + attachHls(); + + return () => { + isCancelled = true; + if (hlsInstance) { + hlsInstance.destroy(); + } + if (mediaElement) { + mediaElement.removeAttribute('src'); + mediaElement.load?.(); + } + }; + }, [directUrl, embedUrl, hlsUrl, type]); + + if (embedUrl) { + return ( +
+