Merge pull request #133 from ragestudio/dev

This commit is contained in:
srgooglo 2025-05-12 04:28:41 +02:00 committed by GitHub
commit 81b0c72d1a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 465 additions and 95 deletions

@ -1 +1 @@
Subproject commit f408866ac27f7eb0ce2dd6abe1cfe4b1902cd7e8 Subproject commit 94c8d7383e84a2de4b193d27adfcb1baa4163f68

@ -1 +1 @@
Subproject commit bc9b82dab1767b2fa1085fcf22336c455a7f89c1 Subproject commit fa61273d5b4b40a22d97c7773321d8ca6c985fd7

View File

@ -1,6 +1,6 @@
{ {
"name": "@comty/app", "name": "@comty/app",
"version": "1.40.0@alpha", "version": "1.41.0@alpha",
"license": "ComtyLicense", "license": "ComtyLicense",
"main": "electron/main", "main": "electron/main",
"type": "module", "type": "module",
@ -33,7 +33,7 @@
"axios": "^1.7.7", "axios": "^1.7.7",
"bear-react-carousel": "^4.0.10-alpha.0", "bear-react-carousel": "^4.0.10-alpha.0",
"classnames": "2.3.1", "classnames": "2.3.1",
"comty.js": "^0.65.0", "comty.js": "^0.65.5",
"d3": "^7.9.0", "d3": "^7.9.0",
"dashjs": "^5.0.0", "dashjs": "^5.0.0",
"dompurify": "^3.0.0", "dompurify": "^3.0.0",

View File

@ -195,9 +195,11 @@ export default class ChunkedUpload {
} }
waitOnSSE(data) { waitOnSSE(data) {
console.log(`[UPLOADER] Connecting to SSE channel >`, data.sseUrl) // temporal solution until comty.js manages this
const url = `${app.cores.api.client().mainOrigin}/upload/sse_events/${data.sseChannelId}`
const eventSource = new EventSource(data.sseUrl) console.log(`[UPLOADER] Connecting to SSE channel >`, url)
const eventSource = new EventSource(url)
eventSource.onerror = (error) => { eventSource.onerror = (error) => {
this.events.emit("error", error) this.events.emit("error", error)

View File

@ -0,0 +1,44 @@
import React from "react"
import Hls from "hls.js"
const StreamPreview = ({ profile }) => {
const videoRef = React.useRef(null)
const hlsInstance = React.useRef(null)
React.useEffect(() => {
hlsInstance.current = new Hls({
maxLiveSyncPlaybackRate: 1.5,
strategy: "bandwidth",
autoplay: true,
})
hlsInstance.current.attachMedia(videoRef.current)
hlsInstance.current.on(Hls.Events.MEDIA_ATTACHED, () => {
hlsInstance.current.loadSource(profile.sources.hls)
})
videoRef.current.addEventListener("play", () => {
console.log("[HLS] Syncing to last position")
videoRef.current.currentTime = hlsInstance.current.liveSyncPosition
})
videoRef.current.play()
return () => {
hlsInstance.current.destroy()
}
}, [])
return (
<video
muted
autoplay
controls
ref={videoRef}
id="stream_preview_player"
/>
)
}
export default StreamPreview

View File

@ -9,7 +9,11 @@ const ProfileHeader = ({ profile, streamHealth }) => {
async function setTimedThumbnail() { async function setTimedThumbnail() {
setThumbnail(() => { setThumbnail(() => {
if (streamRef.current.online && profile.info.thumbnail) { if (
streamRef.current &&
streamRef.current.online &&
profile.info.thumbnail
) {
return `${profile.info.thumbnail}?t=${Date.now()}` return `${profile.info.thumbnail}?t=${Date.now()}`
} }

View File

@ -59,6 +59,10 @@ const ProfileData = (props) => {
return false return false
} }
if (!ws.state.connected) {
return false
}
const health = await ws.call("stream:health", profile_id) const health = await ws.call("stream:health", profile_id)
setStreamHealth(health) setStreamHealth(health)

View File

@ -60,6 +60,7 @@
top: 0; top: 0;
z-index: 10; z-index: 10;
object-fit: cover;
} }
.profile-header__content { .profile-header__content {
@ -217,9 +218,6 @@
background-color: var(--background-color-accent); background-color: var(--background-color-accent);
.ant-segmented-thumb { .ant-segmented-thumb {
left: var(--thumb-active-left);
width: var(--thumb-active-width);
background-color: var(--background-color-primary-2); background-color: var(--background-color-primary-2);
transition: all 150ms ease-in-out; transition: all 150ms ease-in-out;

View File

@ -4,6 +4,7 @@ import UploadButton from "@components/UploadButton"
import { FiImage, FiInfo } from "react-icons/fi" import { FiImage, FiInfo } from "react-icons/fi"
import { MdTextFields, MdDescription } from "react-icons/md" import { MdTextFields, MdDescription } from "react-icons/md"
import StreamPreview from "../../components/StreamPreview"
import StreamRateChart from "../../components/StreamRateChart" import StreamRateChart from "../../components/StreamRateChart"
import { formatBytes, formatBitrate } from "../../liveTabUtils" import { formatBytes, formatBitrate } from "../../liveTabUtils"
import { useStreamSignalQuality } from "../../useStreamSignalQuality" import { useStreamSignalQuality } from "../../useStreamSignalQuality"
@ -146,7 +147,7 @@ const Live = ({ profile, loading, handleProfileUpdate, streamHealth }) => {
</div> </div>
<div className="content-panel__content"> <div className="content-panel__content">
<div className="status-indicator"> <div className="status-indicator">
Stream Status:{" "} Stream Status:
{streamHealth?.online ? ( {streamHealth?.online ? (
<Tag color="green">Online</Tag> <Tag color="green">Online</Tag>
) : ( ) : (
@ -155,9 +156,14 @@ const Live = ({ profile, loading, handleProfileUpdate, streamHealth }) => {
</div> </div>
<div className="live-tab-preview"> <div className="live-tab-preview">
{streamHealth?.online {streamHealth?.online ? (
? "Video Preview Area" <StreamPreview
: "Stream is Offline"} streamHealth={streamHealth}
profile={profile}
/>
) : (
"Stream is Offline"
)}
</div> </div>
</div> </div>
</div> </div>

View File

@ -27,7 +27,10 @@
justify-content: center; justify-content: center;
width: 100%; width: 100%;
min-height: 200px; height: 250px;
max-height: 250px;
min-height: 250px;
background-color: var(--background-color-primary); background-color: var(--background-color-primary);
@ -35,6 +38,14 @@
border-radius: 4px; border-radius: 4px;
font-size: 1rem; font-size: 1rem;
overflow: hidden;
video {
width: 100%;
height: 100%;
object-fit: contain;
}
} }
.live-tab-stats { .live-tab-stats {

View File

@ -7,33 +7,30 @@ import "./index.less"
const ProfileCreator = (props) => { const ProfileCreator = (props) => {
const [loading, setLoading] = React.useState(false) const [loading, setLoading] = React.useState(false)
const [name, setName] = React.useState(props.editValue ?? null) const [title, setTitle] = React.useState(props.editValue ?? null)
function handleChange(e) { function handleChange(e) {
setName(e.target.value.trim()) setTitle(e.target.value.trim())
} }
async function handleSubmit() { async function handleSubmit() {
setLoading(true) setLoading(true)
if (props.editValue) { const result = await Streaming.createProfile({
if (typeof props.onEdit === "function") { info: {
await props.onEdit(name) title: title,
} },
} else { }).catch((error) => {
const result = await Streaming.createProfile({ console.error(error)
profile_name: name, app.message.error("Failed to create")
}).catch((error) => { return null
console.error(error) })
app.message.error("Failed to create")
return null
})
if (result) { if (result) {
app.message.success("Created") app.message.success("Created")
app.eventBus.emit("app:new_profile", result) app.eventBus.emit("app:new_profile", result)
props.onCreate(result._id, result)
} props.onCreate(result._id, result)
} }
props.close() props.close()
@ -44,8 +41,8 @@ const ProfileCreator = (props) => {
return ( return (
<div className="profile-creator"> <div className="profile-creator">
<antd.Input <antd.Input
value={name} value={title}
placeholder="Enter a profile name" placeholder="Enter a profile title"
onChange={handleChange} onChange={handleChange}
/> />
@ -55,12 +52,12 @@ const ProfileCreator = (props) => {
<antd.Button <antd.Button
type="primary" type="primary"
onClick={() => { onClick={() => {
handleSubmit(name) handleSubmit(title)
}} }}
disabled={!name || loading} disabled={!title || loading}
loading={loading} loading={loading}
> >
{props.editValue ? "Update" : "Create"} Create
</antd.Button> </antd.Button>
</div> </div>
</div> </div>

View File

@ -0,0 +1,56 @@
import React from "react"
import * as antd from "antd"
import "./index.less"
const Profile = ({
profile,
onClickManage,
onClickChangeId,
onClickDelete,
}) => {
if (!profile) {
return null
}
return (
<div className="tvstudio-page-list-item">
<div className="tvstudio-page-list-item__id">
<antd.Tag>{profile._id}</antd.Tag>
</div>
<div
className="tvstudio-page-list-item__thumbnail"
style={{
backgroundImage: `url("${profile.info.offline_thumbnail}")`,
}}
onClick={onClickManage}
/>
<div className="tvstudio-page-list-item__content">
<div className="tvstudio-page-list-item__content__title">
<h1>{profile.info.title}</h1>
</div>
<div className="tvstudio-page-list-item__content__description">
<span>{profile.info.description ?? "No description"}</span>
</div>
<div className="tvstudio-page-list-item__content__actions">
<antd.Button size="small" onClick={onClickManage}>
Manage
</antd.Button>
<antd.Button
size="small"
type="danger"
onClick={onClickDelete}
>
Delete
</antd.Button>
</div>
</div>
</div>
)
}
export default Profile

View File

@ -0,0 +1,107 @@
.tvstudio-page-list-item {
position: relative;
display: flex;
flex-direction: row;
align-items: center;
height: 130px;
background-color: var(--background-color-accent);
overflow: hidden;
&:not(:last-child) {
border-bottom: 3px solid var(--border-color);
}
&__id {
position: absolute;
z-index: 100;
top: 0;
right: 0;
padding: 10px;
}
&__thumbnail {
position: absolute;
z-index: 55;
right: 0;
top: 0;
background-size: cover;
background-position: center;
opacity: 0.5;
width: 50%;
height: 100%;
cursor: pointer;
mask-image: linear-gradient(
to right,
rgba(0, 0, 0, 0),
rgba(0, 0, 0, 1)
);
}
&__content {
z-index: 100;
display: flex;
flex-direction: column;
width: 60%;
height: 100%;
padding: 20px 20px;
gap: 5px;
&__title {
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
font-size: 0.8rem;
}
&__description {
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
font-size: 0.7rem;
}
&__actions {
position: absolute;
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
margin: 10px 20px;
bottom: 0;
left: 0;
.ant-btn {
font-size: 0.7rem;
}
}
}
}

View File

@ -1,22 +1,16 @@
import React from "react" import React from "react"
import * as antd from "antd" import * as antd from "antd"
import ProfileCreator from "./components/ProfileCreator"
import Skeleton from "@components/Skeleton" import Skeleton from "@components/Skeleton"
import ProfileCreator from "./components/ProfileCreator"
import ProfileItem from "./components/ProfileItem"
import Streaming from "@models/spectrum" import Streaming from "@models/spectrum"
import useCenteredContainer from "@hooks/useCenteredContainer"
import "./index.less" import "./index.less"
const Profile = ({ profile, onClick }) => {
return <div onClick={onClick}>{profile.profile_name}</div>
}
const TVStudioPage = (props) => { const TVStudioPage = (props) => {
useCenteredContainer(false)
const [loading, list, error, repeat] = app.cores.api.useRequest( const [loading, list, error, repeat] = app.cores.api.useRequest(
Streaming.getOwnProfiles, Streaming.getOwnProfiles,
) )
@ -25,38 +19,74 @@ const TVStudioPage = (props) => {
app.layout.modal.open("tv_profile_creator", ProfileCreator, { app.layout.modal.open("tv_profile_creator", ProfileCreator, {
props: { props: {
onCreate: (id, data) => { onCreate: (id, data) => {
setSelectedProfileId(id) repeat()
}, },
}, },
}) })
} }
function handleProfileClick(id) { function handleDeleteProfileClick(id) {
app.layout.modal.confirm({
headerText: "Delete profile",
descriptionText: "Are you sure you want to delete profile?",
onConfirm: async () => {
const result = await Streaming.deleteProfile(id)
if (result) {
app.message.success("Profile deleted")
repeat()
}
},
})
}
function handleManageProfileClick(id) {
app.location.push(`/studio/tv/${id}`) app.location.push(`/studio/tv/${id}`)
} }
if (error) {
return (
<antd.Result
status="warning"
title="Error"
subTitle="Failed to fetch profiles"
/>
)
}
if (loading) { if (loading) {
return <Skeleton /> return <Skeleton />
} }
return ( return (
<div className="tvstudio-page"> <div className="tvstudio-page">
<div className="tvstudio-page-header">
<h1>TV Studio</h1>
</div>
<div className="tvstudio-page-actions"> <div className="tvstudio-page-actions">
<antd.Button type="primary" onClick={handleNewProfileClick}> <antd.Button type="primary" onClick={handleNewProfileClick}>
Create new Create new
</antd.Button> </antd.Button>
</div> </div>
{list.length > 0 && <div className="tvstudio-page-list">
list.map((profile, index) => { {list.length > 0 &&
return ( list.map((profile, index) => {
<Profile return (
key={index} <ProfileItem
profile={profile} key={index}
onClick={() => handleProfileClick(profile._id)} profile={profile}
/> onClickManage={() =>
) handleManageProfileClick(profile._id)
})} }
onClickDelete={() =>
handleDeleteProfileClick(profile._id)
}
/>
)
})}
</div>
</div> </div>
) )
} }

View File

@ -1,37 +1,57 @@
.tvstudio-page { .tvstudio-page {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: 100%; width: 100%;
gap: 10px; gap: 10px;
.tvstudio-page-actions { .tvstudio-page-header {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: space-between; width: fit-content;
width: 100%; gap: 10px;
gap: 10px; background-color: var(--background-color-accent);
padding: 10px 20px;
.profile-selector { border-radius: 12px;
display: flex;
flex-direction: row;
width: 100%; h1 {
} font-family: "Space Grotesk", sans-serif;
} font-size: 1.3rem;
margin: 0;
}
}
.tvstudio-page-selector-hint { .tvstudio-page-actions {
display: flex; display: flex;
flex-direction: column; flex-direction: row;
align-items: center;
justify-content: center;
width: 100%; justify-content: space-between;
padding: 50px 0; width: 100%;
}
gap: 10px;
.profile-selector {
display: flex;
flex-direction: row;
width: 100%;
}
}
.tvstudio-page-list {
display: flex;
flex-direction: column;
width: 100%;
border-radius: 12px;
overflow: hidden;
}
} }

View File

@ -0,0 +1,7 @@
export default {
"/spectrum/*": {
target: process.env.SPECTRUM_API ?? "https://live.ragestudio.net",
pathRewrite: { "^/spectrum/(.*)": "/$1", "^/spectrum": "/" },
websocket: true,
},
}

View File

@ -165,6 +165,92 @@ export default class Gateway {
} }
} }
/**
* Loads and registers additional proxy routes from ../../extra-proxies.js
*/
async registerExtraProxies() {
try {
// Dynamic import is relative to the current file.
// extra-proxies.js can be CJS (module.exports = ...) or ESM (export default ...)
const extraProxiesModule = require(
path.resolve(process.cwd(), "extra-proxies.js"),
)
const extraProxies = extraProxiesModule.default // Node's CJS/ESM interop puts module.exports on .default
if (
!extraProxies ||
typeof extraProxies !== "object" ||
Object.keys(extraProxies).length === 0
) {
console.log(
"[Gateway] No extra proxies defined in `extra-proxies.js`, file is empty, or format is invalid. Skipping.",
)
return
}
console.log(
`[Gateway] Registering extra proxies from 'extra-proxies.js'...`,
)
for (const proxyPathKey in extraProxies) {
if (
Object.prototype.hasOwnProperty.call(
extraProxies,
proxyPathKey,
)
) {
const config = extraProxies[proxyPathKey]
if (!config || typeof config.target !== "string") {
console.warn(
`[Gateway] Skipping invalid extra proxy config for path: '${proxyPathKey}' in 'extra-proxies.js'. Target is missing or not a string.`,
)
continue
}
let registrationPath = proxyPathKey
// Normalize paths ending with /*
// e.g., "/spectrum/*" becomes "/spectrum"
// e.g., "/*" becomes "/"
if (registrationPath.endsWith("/*")) {
registrationPath = registrationPath.slice(0, -2)
if (registrationPath === "") {
registrationPath = "/"
}
}
console.log(
`[Gateway] Registering extra proxy: '${proxyPathKey}' (as '${registrationPath}') -> ${config.target}`,
)
await this.gateway.register({
serviceId: `extra-proxy:${registrationPath}`, // Unique ID for this proxy rule
path: registrationPath,
target: config.target,
pathRewrite: config.pathRewrite, // undefined if not present
websocket: !!config.websocket, // false if not present or falsy
})
}
}
} catch (error) {
// Handle cases where the extra-proxies.js file might not exist
if (
error.code === "ERR_MODULE_NOT_FOUND" ||
(error.message &&
error.message.toLowerCase().includes("cannot find module"))
) {
console.log(
"[Gateway] `extra-proxies.js` not found. Skipping extra proxy registration.",
)
} else {
console.error(
"[Gateway] Error loading or registering extra proxies from `extra-proxies.js`:",
error,
)
}
}
}
/** /**
* Handle both router and websocket registration requests from services * Handle both router and websocket registration requests from services
* @param {Service} service - Service registering a route or websocket * @param {Service} service - Service registering a route or websocket
@ -304,6 +390,9 @@ export default class Gateway {
await this.gateway.initialize() await this.gateway.initialize()
} }
// Register any externally defined proxies before services start
await this.registerExtraProxies()
// Watch for service state changes // Watch for service state changes
Observable.observe(this.serviceRegistry, (changes) => { Observable.observe(this.serviceRegistry, (changes) => {
this.checkAllServicesReady() this.checkAllServicesReady()

View File

@ -371,19 +371,12 @@ http {
if (route.pathRewrite && Object.keys(route.pathRewrite).length > 0) { if (route.pathRewrite && Object.keys(route.pathRewrite).length > 0) {
rewriteConfig += "# Path rewrite rules\n" rewriteConfig += "# Path rewrite rules\n"
for (const [pattern, replacement] of Object.entries( for (const [pattern, replacement] of Object.entries(
route.pathRewrite, route.pathRewrite,
)) { )) {
// Improved rewrite pattern that preserves query parameters // Improved rewrite pattern that preserves query parameters
rewriteConfig += `\trewrite ${pattern} ${replacement}$is_args$args break;` rewriteConfig += `\nrewrite ${pattern} ${replacement} break;`
}
} else {
// If no explicit rewrite is defined, but we need to strip the path prefix,
// Generate a default rewrite that preserves the URL structure
if (path !== "/") {
rewriteConfig += "# Default path rewrite to strip prefix\n"
rewriteConfig += `\trewrite ^${path}(/.*)$ $1$is_args$args break;\n`
rewriteConfig += `\trewrite ^${path}$ / break;`
} }
} }
@ -423,6 +416,8 @@ ${locationDirective} {
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade"; proxy_set_header Connection "upgrade";
${rewriteConfig}
# Proxy pass to service # Proxy pass to service
proxy_pass ${route.target}; proxy_pass ${route.target};
} }

View File

@ -105,7 +105,7 @@ export default {
return { return {
uploadId: payload.uploadId, uploadId: payload.uploadId,
sseChannelId: job.sseChannelId, sseChannelId: job.sseChannelId,
sseUrl: `${req.headers["x-forwarded-proto"] || req.protocol}://${req.get("host")}/upload/sse_events/${job.sseChannelId}`, sseUrl: `${req.headers["x-forwarded-proto"] || req.protocol}://${req.get("x-forwarded-host") ?? req.get("host")}/upload/sse_events/${job.sseChannelId}`,
} }
} }