Merge pull request #129 from ragestudio/dev

Merge 1.38.0
This commit is contained in:
srgooglo 2025-04-24 14:11:46 +02:00 committed by GitHub
commit 88a4e063a6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
157 changed files with 4528 additions and 4868 deletions

@ -1 +1 @@
Subproject commit ff38d45b9686ccbd2e902477bde4cd7eb7d251e8 Subproject commit 57d8b4bed14b0b35d1d9753847ac39710e0d9be5

View File

@ -1,6 +1,6 @@
{ {
"name": "@comty/app", "name": "@comty/app",
"version": "1.37.1@alpha", "version": "1.38.0@alpha",
"license": "ComtyLicense", "license": "ComtyLicense",
"main": "electron/main", "main": "electron/main",
"type": "module", "type": "module",
@ -34,7 +34,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.63.1", "comty.js": "^0.63.1",
"dashjs": "^4.7.4", "dashjs": "^5.0.0",
"dompurify": "^3.0.0", "dompurify": "^3.0.0",
"fast-average-color": "^9.2.0", "fast-average-color": "^9.2.0",
"fuse.js": "6.5.3", "fuse.js": "6.5.3",
@ -49,6 +49,7 @@
"moment": "2.29.4", "moment": "2.29.4",
"motion": "^12.4.2", "motion": "^12.4.2",
"mpegts.js": "^1.6.10", "mpegts.js": "^1.6.10",
"music-metadata": "^11.2.1",
"plyr": "^3.7.8", "plyr": "^3.7.8",
"prop-types": "^15.8.1", "prop-types": "^15.8.1",
"qs": "^6.14.0", "qs": "^6.14.0",

View File

@ -170,7 +170,7 @@ export default class ChunkedUpload {
// check if is the last chunk, if so, handle sse events // check if is the last chunk, if so, handle sse events
if (this.chunkCount === this.totalChunks) { if (this.chunkCount === this.totalChunks) {
if (data.sseChannelId || data.eventChannelURL) { if (data.sseChannelId || data.sseUrl) {
this.waitOnSSE(data) this.waitOnSSE(data)
} else { } else {
this.events.emit("finish", data) this.events.emit("finish", data)
@ -178,9 +178,8 @@ export default class ChunkedUpload {
} }
this.events.emit("progress", { this.events.emit("progress", {
percentProgress: Math.round( percent: Math.round((100 / this.totalChunks) * this.chunkCount),
(100 / this.totalChunks) * this.chunkCount, state: "Uploading",
),
}) })
} catch (error) { } catch (error) {
this.events.emit("error", error) this.events.emit("error", error)
@ -196,12 +195,9 @@ export default class ChunkedUpload {
} }
waitOnSSE(data) { waitOnSSE(data) {
console.log( console.log(`[UPLOADER] Connecting to SSE channel >`, data.sseUrl)
`[UPLOADER] Connecting to SSE channel >`,
data.eventChannelURL,
)
const eventSource = new EventSource(data.eventChannelURL) const eventSource = new EventSource(data.sseUrl)
eventSource.onerror = (error) => { eventSource.onerror = (error) => {
this.events.emit("error", error) this.events.emit("error", error)
@ -218,19 +214,20 @@ export default class ChunkedUpload {
console.log(`[UPLOADER] SSE Event >`, messageData) console.log(`[UPLOADER] SSE Event >`, messageData)
if (messageData.status === "done") { if (messageData.event === "done") {
this.events.emit("finish", messageData.result) this.events.emit("finish", messageData.result)
eventSource.close() eventSource.close()
} }
if (messageData.status === "error") { if (messageData.event === "error") {
this.events.emit("error", messageData.result) this.events.emit("error", messageData.result)
eventSource.close() eventSource.close()
} }
if (messageData.status === "progress") { if (messageData.state) {
this.events.emit("progress", { this.events.emit("progress", {
percentProgress: messageData.progress, percent: messageData.percent,
state: messageData.state,
}) })
} }
} }

View File

@ -7,55 +7,62 @@ import UploadButton from "@components/UploadButton"
import "./index.less" import "./index.less"
const CoverEditor = (props) => { const CoverEditor = (props) => {
const { value, onChange, defaultUrl } = props const { value, onChange, defaultUrl } = props
const [init, setInit] = React.useState(true) const [init, setInit] = React.useState(true)
const [url, setUrl] = React.useState(value) const [url, setUrl] = React.useState(value)
React.useEffect(() => { React.useEffect(() => {
if (!init) { if (!init) {
onChange(url) onChange(url)
} }
}, [url]) }, [url])
React.useEffect(() => { React.useEffect(() => {
if (!value) { if (!value) {
setUrl(defaultUrl) setUrl(defaultUrl)
} else { } else {
setUrl(value) setUrl(value)
} }
setInit(false) setInit(false)
}, []) }, [])
return <div className="cover-editor"> // Handle when value prop change
<div className="cover-editor-preview"> React.useEffect(() => {
<Image if (!value) {
src={url} setUrl(defaultUrl)
/> } else {
</div> setUrl(value)
}
}, [value])
<div className="cover-editor-actions"> return (
<UploadButton <div className="cover-editor">
onSuccess={(uid, response) => { <div className="cover-editor-preview">
setUrl(response.url) <Image src={url} />
}} </div>
/>
<antd.Button <div className="cover-editor-actions">
type="primary" <UploadButton
onClick={() => { onSuccess={(uid, response) => {
setUrl(defaultUrl) setUrl(response.url)
}} }}
> />
Reset
</antd.Button>
{ <antd.Button
props.extraActions type="primary"
} onClick={() => {
</div> setUrl(defaultUrl)
</div> }}
>
Reset
</antd.Button>
{props.extraActions}
</div>
</div>
)
} }
export default CoverEditor export default CoverEditor

View File

@ -392,9 +392,7 @@ const PlaylistView = (props) => {
key={item._id} key={item._id}
order={item._id} order={item._id}
track={item} track={item}
onClickPlayBtn={() => onPlay={() => handleOnClickTrack(item)}
handleOnClickTrack(item)
}
changeState={(update) => changeState={(update) =>
handleTrackChangeState( handleTrackChangeState(
item._id, item._id,
@ -418,7 +416,7 @@ const PlaylistView = (props) => {
<MusicTrack <MusicTrack
order={index + 1} order={index + 1}
track={item} track={item}
onClickPlayBtn={() => onPlay={() =>
handleOnClickTrack(item) handleOnClickTrack(item)
} }
changeState={(update) => changeState={(update) =>

View File

@ -52,6 +52,10 @@ const Track = (props) => {
const isPlaying = isCurrent && playback_status === "playing" const isPlaying = isCurrent && playback_status === "playing"
const handleClickPlayBtn = React.useCallback(() => { const handleClickPlayBtn = React.useCallback(() => {
if (typeof props.onPlay === "function") {
return props.onPlay(props.track)
}
if (typeof props.onClickPlayBtn === "function") { if (typeof props.onClickPlayBtn === "function") {
props.onClickPlayBtn(props.track) props.onClickPlayBtn(props.track)
} }

View File

@ -12,95 +12,96 @@ import "./index.less"
dayjs.extend(customParseFormat) dayjs.extend(customParseFormat)
const VideoEditor = (props) => { const VideoEditor = (props) => {
function handleChange(key, value) { function handleChange(key, value) {
if (typeof props.onChange !== "function") { if (typeof props.onChange !== "function") {
return false return false
} }
props.onChange(key, value) props.onChange(key, value)
} }
return <div className="video-editor"> return (
<h1> <div className="video-editor">
<Icons.MdVideocam /> <h1>
Video <Icons.MdVideocam />
</h1> Video
</h1>
{ {!props.videoSourceURL && (
(!props.videoSourceURL) && <antd.Empty <antd.Empty
image={<Icons.MdVideocam />} image={<Icons.MdVideocam />}
description="No video" description="No video"
/> />
} )}
{ {props.videoSourceURL && (
props.videoSourceURL && <div className="video-editor-preview"> <div className="video-editor-preview">
<VideoPlayer <VideoPlayer
controls={[ controls={[
"play", "play",
"current-time", "current-time",
"seek-time", "seek-time",
"duration", "duration",
"progress", "progress",
"settings", "settings",
]} ]}
src={props.videoSourceURL} src={props.videoSourceURL}
/> />
</div> </div>
} )}
<div className="flex-column align-start gap10"> <div className="flex-column align-start gap10">
<div className="flex-row align-center gap10"> <div className="flex-row align-center gap10">
<span> <span>
<Icons.MdAccessTime /> <Icons.MdAccessTime />
Start video sync at Start video sync at
</span> </span>
<code>{props.startSyncAt ?? "not set"}</code> <code>{props.startSyncAt ?? "not set"}</code>
</div> </div>
<div className="flex-row align-center gap10"> <div className="flex-row align-center gap10">
<span>Set to:</span> <span>Set to:</span>
<antd.TimePicker <antd.TimePicker
showNow={false} showNow={false}
defaultValue={props.startSyncAt && dayjs((props.startSyncAt), "mm:ss:SSS")} defaultValue={
format={"mm:ss:SSS"} props.startSyncAt &&
onChange={(time, str) => { dayjs(props.startSyncAt, "mm:ss:SSS")
handleChange("startSyncAt", str) }
}} format={"mm:ss:SSS"}
/> onChange={(time, str) => {
</div> handleChange("startSyncAt", str)
</div> }}
/>
</div>
</div>
<div className="video-editor-actions"> <div className="video-editor-actions">
<UploadButton <UploadButton
onSuccess={(id, response) => { onSuccess={(id, response) => {
handleChange("videoSourceURL", response.url) handleChange("videoSourceURL", response.url)
}} }}
accept={[ accept={["video/*"]}
"video/*", headers={{
]} transformations: "mq-hls",
headers={{ }}
"transmux": "mq-hls", disabled={props.loading}
}} >
disabled={props.loading} Upload video
> </UploadButton>
Upload video or
</UploadButton> <antd.Input
placeholder="Set a video HLS URL"
or onChange={(e) => {
handleChange("videoSourceURL", e.target.value)
<antd.Input }}
placeholder="Set a video HLS URL" value={props.videoSourceURL}
onChange={(e) => { disabled={props.loading}
handleChange("videoSourceURL", e.target.value) />
}} </div>
value={props.videoSourceURL} </div>
disabled={props.loading} )
/>
</div>
</div>
} }
export default VideoEditor export default VideoEditor

View File

@ -1,280 +1,332 @@
import React from "react" import React from "react"
import * as antd from "antd" import * as antd from "antd"
import { Icons, createIconRender } from "@components/Icons" import { Icons, createIconRender } from "@components/Icons"
import MusicModel from "@models/music" import MusicModel from "@models/music"
import compareObjectsByProperties from "@utils/compareObjectsByProperties"
import useUrlQueryActiveKey from "@hooks/useUrlQueryActiveKey" import useUrlQueryActiveKey from "@hooks/useUrlQueryActiveKey"
import TrackManifest from "@cores/player/classes/TrackManifest" import TrackManifest from "@cores/player/classes/TrackManifest"
import { DefaultReleaseEditorState, ReleaseEditorStateContext } from "@contexts/MusicReleaseEditor" import {
DefaultReleaseEditorState,
ReleaseEditorStateContext,
} from "@contexts/MusicReleaseEditor"
import Tabs from "./tabs" import Tabs from "./tabs"
import "./index.less" import "./index.less"
const ReleaseEditor = (props) => { const ReleaseEditor = (props) => {
const { release_id } = props const { release_id } = props
const basicInfoRef = React.useRef() const basicInfoRef = React.useRef()
const [submitting, setSubmitting] = React.useState(false) const [submitting, setSubmitting] = React.useState(false)
const [loading, setLoading] = React.useState(true) const [loading, setLoading] = React.useState(true)
const [submitError, setSubmitError] = React.useState(null) const [submitError, setSubmitError] = React.useState(null)
const [loadError, setLoadError] = React.useState(null) const [loadError, setLoadError] = React.useState(null)
const [globalState, setGlobalState] = React.useState(DefaultReleaseEditorState) const [globalState, setGlobalState] = React.useState(
DefaultReleaseEditorState,
)
const [initialValues, setInitialValues] = React.useState({})
const [customPage, setCustomPage] = React.useState(null) const [customPage, setCustomPage] = React.useState(null)
const [customPageActions, setCustomPageActions] = React.useState([]) const [customPageActions, setCustomPageActions] = React.useState([])
const [selectedTab, setSelectedTab] = useUrlQueryActiveKey({ const [selectedTab, setSelectedTab] = useUrlQueryActiveKey({
defaultKey: "info", defaultKey: "info",
queryKey: "tab" queryKey: "tab",
}) })
async function initialize() { async function initialize() {
setLoading(true) setLoading(true)
setLoadError(null) setLoadError(null)
if (release_id !== "new") { if (release_id !== "new") {
try { try {
let releaseData = await MusicModel.getReleaseData(release_id) let releaseData = await MusicModel.getReleaseData(release_id)
if (Array.isArray(releaseData.list)) { if (Array.isArray(releaseData.items)) {
releaseData.list = releaseData.list.map((item) => { releaseData.items = releaseData.items.map((item) => {
return new TrackManifest(item) return new TrackManifest(item)
}) })
} }
setGlobalState({ setGlobalState({
...globalState, ...globalState,
...releaseData, ...releaseData,
}) })
} catch (error) {
setLoadError(error)
}
}
setLoading(false) setInitialValues(releaseData)
} } catch (error) {
setLoadError(error)
}
}
async function renderCustomPage(page, actions) { setLoading(false)
setCustomPage(page ?? null) }
setCustomPageActions(actions ?? [])
}
async function handleSubmit() { function hasChanges() {
setSubmitting(true) const stagedChanges = {
setSubmitError(null) title: globalState.title,
type: globalState.type,
public: globalState.public,
cover: globalState.cover,
items: globalState.items,
}
try { return !compareObjectsByProperties(
// first sumbit tracks stagedChanges,
const tracks = await MusicModel.putTrack({ initialValues,
list: globalState.list, Object.keys(stagedChanges),
}) )
}
// then submit release async function renderCustomPage(page, actions) {
const result = await MusicModel.putRelease({ setCustomPage(page ?? null)
_id: globalState._id, setCustomPageActions(actions ?? [])
title: globalState.title, }
description: globalState.description,
public: globalState.public,
cover: globalState.cover,
explicit: globalState.explicit,
type: globalState.type,
list: tracks.list.map((item) => item._id),
})
app.location.push(`/studio/music/${result._id}`) async function handleSubmit() {
} catch (error) { setSubmitting(true)
console.error(error) setSubmitError(null)
app.message.error(error.message)
setSubmitError(error) try {
setSubmitting(false) console.log("Submitting Tracks")
return false // first sumbit tracks
} const tracks = await MusicModel.putTrack({
items: globalState.items,
})
setSubmitting(false) console.log("Submitting release")
app.message.success("Release saved")
}
async function handleDelete() { // then submit release
app.layout.modal.confirm({ const result = await MusicModel.putRelease({
headerText: "Are you sure you want to delete this release?", _id: globalState._id,
descriptionText: "This action cannot be undone.", title: globalState.title,
onConfirm: async () => { description: globalState.description,
await MusicModel.deleteRelease(globalState._id) public: globalState.public,
app.location.push(window.location.pathname.split("/").slice(0, -1).join("/")) cover: globalState.cover,
}, explicit: globalState.explicit,
}) type: globalState.type,
} items: tracks.items.map((item) => item._id),
})
async function canFinish() { app.location.push(`/studio/music/${result._id}`)
return true } catch (error) {
} console.error(error)
app.message.error(error.message)
React.useEffect(() => { setSubmitError(error)
initialize() setSubmitting(false)
}, [])
if (loadError) { return false
return <antd.Result }
status="warning"
title="Error"
subTitle={loadError.message}
/>
}
if (loading) { setSubmitting(false)
return <antd.Skeleton active /> app.message.success("Release saved")
} }
const Tab = Tabs.find(({ key }) => key === selectedTab) async function handleDelete() {
app.layout.modal.confirm({
headerText: "Are you sure you want to delete this release?",
descriptionText: "This action cannot be undone.",
onConfirm: async () => {
await MusicModel.deleteRelease(globalState._id)
app.location.push(
window.location.pathname.split("/").slice(0, -1).join("/"),
)
},
})
}
const CustomPageProps = { function canFinish() {
close: () => { return hasChanges()
renderCustomPage(null, null) }
}
}
return <ReleaseEditorStateContext.Provider React.useEffect(() => {
value={{ initialize()
...globalState, }, [])
setGlobalState,
renderCustomPage,
setCustomPageActions,
}}
>
<div className="music-studio-release-editor">
{
customPage && <div className="music-studio-release-editor-custom-page">
{
customPage.header && <div className="music-studio-release-editor-custom-page-header">
<div className="music-studio-release-editor-custom-page-header-title">
<antd.Button
icon={<Icons.IoIosArrowBack />}
onClick={() => renderCustomPage(null, null)}
/>
<h2>{customPage.header}</h2> if (loadError) {
</div> return (
<antd.Result
status="warning"
title="Error"
subTitle={loadError.message}
/>
)
}
{ if (loading) {
Array.isArray(customPageActions) && customPageActions.map((action, index) => { return <antd.Skeleton active />
return <antd.Button }
key={index}
type={action.type}
icon={createIconRender(action.icon)}
onClick={async () => {
if (typeof action.onClick === "function") {
await action.onClick()
}
if (action.fireEvent) { const Tab = Tabs.find(({ key }) => key === selectedTab)
app.eventBus.emit(action.fireEvent)
}
}}
disabled={action.disabled}
>
{action.label}
</antd.Button>
})
}
</div>
}
{ const CustomPageProps = {
customPage.content && (React.isValidElement(customPage.content) ? close: () => {
React.cloneElement(customPage.content, { renderCustomPage(null, null)
...CustomPageProps, },
...customPage.props }
}) :
React.createElement(customPage.content, {
...CustomPageProps,
...customPage.props
})
)
}
</div>
}
{
!customPage && <>
<div className="music-studio-release-editor-menu">
<antd.Menu
onClick={(e) => setSelectedTab(e.key)}
selectedKeys={[selectedTab]}
items={Tabs}
mode="vertical"
/>
<div className="music-studio-release-editor-menu-actions"> return (
<antd.Button <ReleaseEditorStateContext.Provider
type="primary" value={{
onClick={handleSubmit} ...globalState,
icon={release_id !== "new" ? <Icons.FiSave /> : <Icons.MdSend />} setGlobalState,
disabled={submitting || loading || !canFinish()} renderCustomPage,
loading={submitting} setCustomPageActions,
> }}
{release_id !== "new" ? "Save" : "Release"} >
</antd.Button> <div className="music-studio-release-editor">
{customPage && (
<div className="music-studio-release-editor-custom-page">
{customPage.header && (
<div className="music-studio-release-editor-custom-page-header">
<div className="music-studio-release-editor-custom-page-header-title">
<antd.Button
icon={<Icons.IoIosArrowBack />}
onClick={() =>
renderCustomPage(null, null)
}
/>
{ <h2>{customPage.header}</h2>
release_id !== "new" ? <antd.Button </div>
icon={<Icons.IoMdTrash />}
disabled={loading}
onClick={handleDelete}
>
Delete
</antd.Button> : null
}
{ {Array.isArray(customPageActions) &&
release_id !== "new" ? <antd.Button customPageActions.map((action, index) => {
icon={<Icons.MdLink />} return (
onClick={() => app.location.push(`/music/release/${globalState._id}`)} <antd.Button
> key={index}
Go to release type={action.type}
</antd.Button> : null icon={createIconRender(
} action.icon,
</div> )}
</div> onClick={async () => {
if (
typeof action.onClick ===
"function"
) {
await action.onClick()
}
<div className="music-studio-release-editor-content"> if (action.fireEvent) {
{ app.eventBus.emit(
submitError && <antd.Alert action.fireEvent,
message={submitError.message} )
type="error" }
/> }}
} disabled={action.disabled}
{ >
!Tab && <antd.Result {action.label}
status="error" </antd.Button>
title="Error" )
subTitle="Tab not found" })}
/> </div>
} )}
{
Tab && React.createElement(Tab.render, {
release: globalState,
state: globalState, {customPage.content &&
setState: setGlobalState, (React.isValidElement(customPage.content)
? React.cloneElement(customPage.content, {
...CustomPageProps,
...customPage.props,
})
: React.createElement(customPage.content, {
...CustomPageProps,
...customPage.props,
}))}
</div>
)}
{!customPage && (
<>
<div className="music-studio-release-editor-menu">
<antd.Menu
onClick={(e) => setSelectedTab(e.key)}
selectedKeys={[selectedTab]}
items={Tabs}
mode="vertical"
/>
references: { <div className="music-studio-release-editor-menu-actions">
basic: basicInfoRef <antd.Button
} type="primary"
}) onClick={handleSubmit}
} icon={
</div> release_id !== "new" ? (
</> <Icons.FiSave />
} ) : (
</div> <Icons.MdSend />
</ReleaseEditorStateContext.Provider> )
}
disabled={
submitting || loading || !canFinish()
}
loading={submitting}
>
{release_id !== "new" ? "Save" : "Release"}
</antd.Button>
{release_id !== "new" ? (
<antd.Button
icon={<Icons.IoMdTrash />}
disabled={loading}
onClick={handleDelete}
>
Delete
</antd.Button>
) : null}
{release_id !== "new" ? (
<antd.Button
icon={<Icons.MdLink />}
onClick={() =>
app.location.push(
`/music/release/${globalState._id}`,
)
}
>
Go to release
</antd.Button>
) : null}
</div>
</div>
<div className="music-studio-release-editor-content">
{submitError && (
<antd.Alert
message={submitError.message}
type="error"
/>
)}
{!Tab && (
<antd.Result
status="error"
title="Error"
subTitle="Tab not found"
/>
)}
{Tab &&
React.createElement(Tab.render, {
release: globalState,
state: globalState,
setState: setGlobalState,
references: {
basic: basicInfoRef,
},
})}
</div>
</>
)}
</div>
</ReleaseEditorStateContext.Provider>
)
} }
export default ReleaseEditor export default ReleaseEditor

View File

@ -11,13 +11,27 @@ import { ReleaseEditorStateContext } from "@contexts/MusicReleaseEditor"
import "./index.less" import "./index.less"
const stateToString = {
uploading: "Uploading",
transmuxing: "Processing...",
uploading_s3: "Archiving...",
}
const getTitleString = ({ track, progress }) => {
if (progress) {
return stateToString[progress.state] || progress.state
}
return track.title
}
const TrackListItem = (props) => { const TrackListItem = (props) => {
const context = React.useContext(ReleaseEditorStateContext) const context = React.useContext(ReleaseEditorStateContext)
const [loading, setLoading] = React.useState(false) const [loading, setLoading] = React.useState(false)
const [error, setError] = React.useState(null) const [error, setError] = React.useState(null)
const { track } = props const { track, progress } = props
async function onClickEditTrack() { async function onClickEditTrack() {
context.renderCustomPage({ context.renderCustomPage({
@ -33,8 +47,6 @@ const TrackListItem = (props) => {
props.onDelete(track.uid) props.onDelete(track.uid)
} }
console.log("render")
return ( return (
<div <div
className={classnames( className={classnames(
@ -50,7 +62,7 @@ const TrackListItem = (props) => {
<div <div
className="music-studio-release-editor-tracks-list-item-progress" className="music-studio-release-editor-tracks-list-item-progress"
style={{ style={{
"--upload-progress": `${props.uploading.progress}%`, "--upload-progress": `${props.progress?.percent ?? 0}%`,
}} }}
/> />
@ -58,7 +70,7 @@ const TrackListItem = (props) => {
<span>{props.index + 1}</span> <span>{props.index + 1}</span>
</div> </div>
{props.uploading.working && <Icons.LoadingOutlined />} {progress !== null && <Icons.LoadingOutlined />}
<Image <Image
src={track.cover} src={track.cover}
@ -69,7 +81,7 @@ const TrackListItem = (props) => {
}} }}
/> />
<span>{track.title}</span> <span>{getTitleString({ track, progress })}</span>
<div className="music-studio-release-editor-tracks-list-item-actions"> <div className="music-studio-release-editor-tracks-list-item-actions">
<antd.Popconfirm <antd.Popconfirm

View File

@ -17,12 +17,12 @@ class TracksManager extends React.Component {
swapyRef = React.createRef() swapyRef = React.createRef()
state = { state = {
list: Array.isArray(this.props.list) ? this.props.list : [], items: Array.isArray(this.props.items) ? this.props.items : [],
pendingUploads: [], pendingUploads: [],
} }
componentDidUpdate = (prevProps, prevState) => { componentDidUpdate = (prevProps, prevState) => {
if (prevState.list !== this.state.list) { if (prevState.items !== this.state.items) {
if (typeof this.props.onChangeState === "function") { if (typeof this.props.onChangeState === "function") {
this.props.onChangeState(this.state) this.props.onChangeState(this.state)
} }
@ -55,7 +55,7 @@ class TracksManager extends React.Component {
return false return false
} }
return this.state.list.find((item) => item.uid === uid) return this.state.items.find((item) => item.uid === uid)
} }
addTrackToList = (track) => { addTrackToList = (track) => {
@ -64,7 +64,7 @@ class TracksManager extends React.Component {
} }
this.setState({ this.setState({
list: [...this.state.list, track], items: [...this.state.items, track],
}) })
} }
@ -76,18 +76,17 @@ class TracksManager extends React.Component {
this.removeTrackUIDFromPendingUploads(uid) this.removeTrackUIDFromPendingUploads(uid)
this.setState({ this.setState({
list: this.state.list.filter((item) => item.uid !== uid), items: this.state.items.filter((item) => item.uid !== uid),
}) })
} }
modifyTrackByUid = (uid, track) => { modifyTrackByUid = (uid, track) => {
console.log("modifyTrackByUid", uid, track)
if (!uid || !track) { if (!uid || !track) {
return false return false
} }
this.setState({ this.setState({
list: this.state.list.map((item) => { items: this.state.items.map((item) => {
if (item.uid === uid) { if (item.uid === uid) {
return { return {
...item, ...item,
@ -140,7 +139,7 @@ class TracksManager extends React.Component {
) )
if (uploadProgressIndex === -1) { if (uploadProgressIndex === -1) {
return 0 return null
} }
return this.state.pendingUploads[uploadProgressIndex].progress return this.state.pendingUploads[uploadProgressIndex].progress
@ -159,7 +158,7 @@ class TracksManager extends React.Component {
newData[uploadProgressIndex].progress = progress newData[uploadProgressIndex].progress = progress
console.log(`Updating progress for [${uid}] to [${progress}]`) console.log(`Updating progress for [${uid}] to >`, progress)
this.setState({ this.setState({
pendingUploads: newData, pendingUploads: newData,
@ -177,8 +176,7 @@ class TracksManager extends React.Component {
const trackManifest = new TrackManifest({ const trackManifest = new TrackManifest({
uid: uid, uid: uid,
file: change.file, file: change.file.originFileObj,
onChange: this.modifyTrackByUid,
}) })
this.addTrackToList(trackManifest) this.addTrackToList(trackManifest)
@ -189,7 +187,7 @@ class TracksManager extends React.Component {
// remove pending file // remove pending file
this.removeTrackUIDFromPendingUploads(uid) this.removeTrackUIDFromPendingUploads(uid)
let trackManifest = this.state.list.find( let trackManifest = this.state.items.find(
(item) => item.uid === uid, (item) => item.uid === uid,
) )
@ -206,6 +204,23 @@ class TracksManager extends React.Component {
trackManifest.source = change.file.response.url trackManifest.source = change.file.response.url
trackManifest = await trackManifest.initialize() trackManifest = await trackManifest.initialize()
// if has a cover, Upload
if (trackManifest._coverBlob) {
console.log(
`[${trackManifest.uid}] Founded cover, uploading...`,
)
const coverFile = new File(
[trackManifest._coverBlob],
"cover.jpg",
{ type: trackManifest._coverBlob.type },
)
const coverUpload =
await app.cores.remoteStorage.uploadFile(coverFile)
trackManifest.cover = coverUpload.url
}
await this.modifyTrackByUid(uid, trackManifest) await this.modifyTrackByUid(uid, trackManifest)
break break
@ -231,9 +246,8 @@ class TracksManager extends React.Component {
const response = await app.cores.remoteStorage const response = await app.cores.remoteStorage
.uploadFile(req.file, { .uploadFile(req.file, {
onProgress: this.handleTrackFileUploadProgress, onProgress: this.handleTrackFileUploadProgress,
service: "b2",
headers: { headers: {
transmux: "a-dash", transformations: "a-dash",
}, },
}) })
.catch((error) => { .catch((error) => {
@ -258,17 +272,17 @@ class TracksManager extends React.Component {
this.setState((prev) => { this.setState((prev) => {
// move all list items by id // move all list items by id
const orderedIds = orderedIdsArray.map((id) => const orderedIds = orderedIdsArray.map((id) =>
this.state.list.find((item) => item._id === id), this.state.items.find((item) => item._id === id),
) )
console.log("orderedIds", orderedIds) console.log("orderedIds", orderedIds)
return { return {
list: orderedIds, items: orderedIds,
} }
}) })
} }
render() { render() {
console.log(`Tracks List >`, this.state.list) console.log(`Tracks List >`, this.state.items)
return ( return (
<div className="music-studio-release-editor-tracks"> <div className="music-studio-release-editor-tracks">
@ -280,7 +294,7 @@ class TracksManager extends React.Component {
accept="audio/*" accept="audio/*"
multiple multiple
> >
{this.state.list.length === 0 ? ( {this.state.items.length === 0 ? (
<UploadHint /> <UploadHint />
) : ( ) : (
<antd.Button <antd.Button
@ -296,11 +310,11 @@ class TracksManager extends React.Component {
id="editor-tracks-list" id="editor-tracks-list"
className="music-studio-release-editor-tracks-list" className="music-studio-release-editor-tracks-list"
> >
{this.state.list.length === 0 && ( {this.state.items.length === 0 && (
<antd.Result status="info" title="No tracks" /> <antd.Result status="info" title="No tracks" />
)} )}
{this.state.list.map((track, index) => { {this.state.items.map((track, index) => {
const progress = this.getUploadProgress(track.uid) const progress = this.getUploadProgress(track.uid)
return ( return (
@ -310,12 +324,7 @@ class TracksManager extends React.Component {
track={track} track={track}
onEdit={this.modifyTrackByUid} onEdit={this.modifyTrackByUid}
onDelete={this.removeTrackByUid} onDelete={this.removeTrackByUid}
uploading={{ progress={progress}
progress: progress,
working: this.state.pendingUploads.find(
(item) => item.uid === track.uid,
),
}}
disabled={progress > 0} disabled={progress > 0}
/> />
</div> </div>
@ -336,7 +345,7 @@ const ReleaseTracks = (props) => {
<TracksManager <TracksManager
_id={state._id} _id={state._id}
list={state.list} items={state.items}
onChangeState={(managerState) => { onChangeState={(managerState) => {
setState({ setState({
...state, ...state,

View File

@ -10,158 +10,163 @@ import { ReleaseEditorStateContext } from "@contexts/MusicReleaseEditor"
import "./index.less" import "./index.less"
const TrackEditor = (props) => { const TrackEditor = (props) => {
const context = React.useContext(ReleaseEditorStateContext) const context = React.useContext(ReleaseEditorStateContext)
const [track, setTrack] = React.useState(props.track ?? {}) const [track, setTrack] = React.useState(props.track ?? {})
async function handleChange(key, value) { async function handleChange(key, value) {
setTrack((prev) => { setTrack((prev) => {
return { return {
...prev, ...prev,
[key]: value [key]: value,
} }
}) })
} }
async function openEnhancedLyricsEditor() { async function openEnhancedLyricsEditor() {
context.renderCustomPage({ context.renderCustomPage({
header: "Enhanced Lyrics", header: "Enhanced Lyrics",
content: EnhancedLyricsEditor, content: EnhancedLyricsEditor,
props: { props: {
track: track, track: track,
} },
}) })
} }
async function handleOnSave() { async function handleOnSave() {
setTrack((prev) => { setTrack((prev) => {
const listData = [...context.list] const listData = [...context.items]
const trackIndex = listData.findIndex((item) => item.uid === prev.uid) const trackIndex = listData.findIndex(
(item) => item.uid === prev.uid,
)
if (trackIndex === -1) { if (trackIndex === -1) {
return prev return prev
} }
listData[trackIndex] = prev listData[trackIndex] = prev
context.setGlobalState({ context.setGlobalState({
...context, ...context,
list: listData items: listData,
}) })
return prev props.close()
})
}
React.useEffect(() => { return prev
context.setCustomPageActions([ })
{ }
label: "Save",
icon: "FiSave",
type: "primary",
onClick: handleOnSave,
disabled: props.track === track,
},
])
}, [track])
return <div className="track-editor"> function setParentCover() {
<div className="track-editor-field"> handleChange("cover", context.cover)
<div className="track-editor-field-header"> }
<Icons.MdImage />
<span>Cover</span>
</div>
<CoverEditor React.useEffect(() => {
value={track.cover} context.setCustomPageActions([
onChange={(url) => handleChange("cover", url)} {
extraActions={[ label: "Save",
<antd.Button> icon: "FiSave",
Use Parent type: "primary",
</antd.Button> onClick: handleOnSave,
]} disabled: props.track === track,
/> },
</div> ])
}, [track])
<div className="track-editor-field"> return (
<div className="track-editor-field-header"> <div className="track-editor">
<Icons.MdOutlineMusicNote /> <div className="track-editor-field">
<span>Title</span> <div className="track-editor-field-header">
</div> <Icons.MdImage />
<span>Cover</span>
</div>
<antd.Input <CoverEditor
value={track.title} value={track.cover}
placeholder="Track title" onChange={(url) => handleChange("cover", url)}
onChange={(e) => handleChange("title", e.target.value)} extraActions={[
/> <antd.Button onClick={setParentCover}>
</div> Use Parent
</antd.Button>,
]}
/>
</div>
<div className="track-editor-field"> <div className="track-editor-field">
<div className="track-editor-field-header"> <div className="track-editor-field-header">
<Icons.FiUser /> <Icons.MdOutlineMusicNote />
<span>Artist</span> <span>Title</span>
</div> </div>
<antd.Input <antd.Input
value={track.artists?.join(", ")} value={track.title}
placeholder="Artist" placeholder="Track title"
onChange={(e) => handleChange("artist", e.target.value)} onChange={(e) => handleChange("title", e.target.value)}
/> />
</div> </div>
<div className="track-editor-field"> <div className="track-editor-field">
<div className="track-editor-field-header"> <div className="track-editor-field-header">
<Icons.MdAlbum /> <Icons.FiUser />
<span>Album</span> <span>Artist</span>
</div> </div>
<antd.Input <antd.Input
value={track.album} value={track.artist}
placeholder="Album" placeholder="Artist"
onChange={(e) => handleChange("album", e.target.value)} onChange={(e) => handleChange("artist", e.target.value)}
/> />
</div> </div>
<div className="track-editor-field"> <div className="track-editor-field">
<div className="track-editor-field-header"> <div className="track-editor-field-header">
<Icons.MdExplicit /> <Icons.MdAlbum />
<span>Explicit</span> <span>Album</span>
</div> </div>
<antd.Switch <antd.Input
checked={track.explicit} value={track.album}
onChange={(value) => handleChange("explicit", value)} placeholder="Album"
/> onChange={(e) => handleChange("album", e.target.value)}
</div> />
</div>
<div className="track-editor-field"> <div className="track-editor-field">
<div className="track-editor-field-header"> <div className="track-editor-field-header">
<Icons.MdLyrics /> <Icons.MdExplicit />
<span>Enhanced Lyrics</span> <span>Explicit</span>
</div>
<antd.Switch <antd.Switch
checked={track.lyrics_enabled} checked={track.explicit}
onChange={(value) => handleChange("lyrics_enabled", value)} onChange={(value) => handleChange("explicit", value)}
disabled={!track.params._id} />
/> </div>
</div>
<div className="track-editor-field-actions"> <div className="track-editor-field">
<antd.Button <div className="track-editor-field-header">
disabled={!track.params._id} <Icons.MdLyrics />
onClick={openEnhancedLyricsEditor} <span>Enhanced Lyrics</span>
> </div>
Edit
</antd.Button>
{ <div className="track-editor-field-actions">
!track.params._id && <span> <antd.Button
You cannot edit Video and Lyrics without release first disabled={!track.params._id}
</span> onClick={openEnhancedLyricsEditor}
} >
</div> Edit
</div> </antd.Button>
</div>
{!track.params._id && (
<span>
You cannot edit Video and Lyrics without release
first
</span>
)}
</div>
</div>
</div>
)
} }
export default TrackEditor export default TrackEditor

View File

@ -6,41 +6,51 @@ import LikeButton from "@components/LikeButton"
import { usePlayerStateContext } from "@contexts/WithPlayerContext" import { usePlayerStateContext } from "@contexts/WithPlayerContext"
import "./index.less"
const ExtraActions = (props) => { const ExtraActions = (props) => {
const [playerState] = usePlayerStateContext() const [trackInstance, setTrackInstance] = React.useState({})
const onPlayerStateChange = React.useCallback((state) => {
const instance = app.cores.player.track()
if (instance) {
setTrackInstance(instance)
}
}, [])
const [playerState] = usePlayerStateContext(onPlayerStateChange)
const handleClickLike = async () => { const handleClickLike = async () => {
if (!playerState.track_manifest) { if (!trackInstance) {
console.error("Cannot like a track if nothing is playing") console.error("Cannot like a track if nothing is playing")
return false return false
} }
const track = app.cores.player.track() await trackInstance.manifest.serviceOperations.toggleItemFavourite(
await track.manifest.serviceOperations.toggleItemFavourite(
"track", "track",
playerState.track_manifest._id, trackInstance.manifest._id,
) )
} }
return ( return (
<div className="extra_actions"> <div className="player-actions">
{app.isMobile && ( {app.isMobile && (
<Button <Button
type="ghost" type="ghost"
icon={<Icons.MdAbc />} icon={<Icons.MdAbc />}
disabled={!playerState.track_manifest?.lyrics_enabled} disabled={!trackInstance?.manifest?.lyrics_enabled}
/> />
)} )}
{!app.isMobile && ( {!app.isMobile && (
<LikeButton <LikeButton
liked={ liked={
playerState.track_manifest?.serviceOperations trackInstance?.manifest?.serviceOperations
.fetchLikeStatus ?.fetchLikeStatus
} }
onClick={handleClickLike} onClick={handleClickLike}
disabled={!playerState.track_manifest?._id} disabled={!trackInstance?.manifest?._id}
/> />
)} )}

View File

@ -0,0 +1,19 @@
.player-actions {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
width: 70%;
margin: auto;
padding: 2px 25px;
background-color: rgba(var(--layoutBackgroundColor), 0.7);
-webkit-backdrop-filter: blur(5px);
backdrop-filter: blur(5px);
border-radius: 12px;
}

View File

@ -47,7 +47,17 @@ const EventsHandlers = {
} }
const Controls = (props) => { const Controls = (props) => {
const [playerState] = usePlayerStateContext() const [trackInstance, setTrackInstance] = React.useState({})
const onPlayerStateChange = React.useCallback((state) => {
const instance = app.cores.player.track()
if (instance) {
setTrackInstance(instance)
}
}, [])
const [playerState] = usePlayerStateContext(onPlayerStateChange)
const handleAction = (event, ...args) => { const handleAction = (event, ...args) => {
if (typeof EventsHandlers[event] !== "function") { if (typeof EventsHandlers[event] !== "function") {
@ -122,10 +132,11 @@ const Controls = (props) => {
{app.isMobile && ( {app.isMobile && (
<LikeButton <LikeButton
liked={ liked={
playerState.track_manifest?.serviceOperations trackInstance?.manifest?.serviceOperations
.fetchLikeStatus ?.fetchLikeStatus
} }
onClick={() => handleAction("like")} onClick={() => handleAction("like")}
disabled={!trackInstance?.manifest?._id}
/> />
)} )}
</div> </div>

View File

@ -8,11 +8,10 @@ import { usePlayerStateContext } from "@contexts/WithPlayerContext"
import LiveInfo from "@components/Player/LiveInfo" import LiveInfo from "@components/Player/LiveInfo"
import SeekBar from "@components/Player/SeekBar" import SeekBar from "@components/Player/SeekBar"
import Controls from "@components/Player/Controls" import Controls from "@components/Player/Controls"
import Actions from "@components/Player/Actions"
import RGBStringToValues from "@utils/rgbToValues" import RGBStringToValues from "@utils/rgbToValues"
import ExtraActions from "../ExtraActions"
import "./index.less" import "./index.less"
function isOverflown(parent, element) { function isOverflown(parent, element) {
@ -93,7 +92,7 @@ const Player = (props) => {
} }
} }
const { title, artistStr, service, cover_analysis, cover } = const { title, artist, service, cover_analysis, cover } =
playerState.track_manifest ?? {} playerState.track_manifest ?? {}
const playing = playerState.playback_status === "playing" const playing = playerState.playback_status === "playing"
@ -201,7 +200,7 @@ const Player = (props) => {
)} )}
<p className="toolbar_player_info_subtitle"> <p className="toolbar_player_info_subtitle">
{artistStr ?? ""} {artist ?? ""}
</p> </p>
</div> </div>
@ -218,7 +217,7 @@ const Player = (props) => {
streamMode={playerState.live} streamMode={playerState.live}
/> />
<ExtraActions streamMode={playerState.live} /> <Actions streamMode={playerState.live} />
</div> </div>
<Indicators <Indicators

View File

@ -7,299 +7,265 @@
@toolbar_player_top_actions_padding_horizontal: 15px; @toolbar_player_top_actions_padding_horizontal: 15px;
.toolbar_player_wrapper { .toolbar_player_wrapper {
position: relative; position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: 100%; width: 100%;
transition: all 150ms ease-in-out; transition: all 150ms ease-in-out;
&.hover { &.hover {
filter: drop-shadow(@card-drop-shadow); filter: drop-shadow(@card-drop-shadow);
.toolbar_player_top_actions { .toolbar_player_top_actions {
height: @toolbar_player_top_actions_height; height: @toolbar_player_top_actions_height;
padding: @toolbar_player_top_actions_padding_vertical @toolbar_player_top_actions_padding_horizontal; padding: @toolbar_player_top_actions_padding_vertical
padding-bottom: calc(calc(@toolbar_player_borderRadius / 2) + @toolbar_player_top_actions_padding_vertical); @toolbar_player_top_actions_padding_horizontal;
} padding-bottom: calc(
} calc(@toolbar_player_borderRadius / 2) +
@toolbar_player_top_actions_padding_vertical
);
}
}
&.cover_light { &.cover_light {
.toolbar_player_content { .toolbar_player_content {
color: var(--text-color-black); color: var(--text-color-black);
} }
.MuiSlider-root { .MuiSlider-root {
color: var(--text-color-black); color: var(--text-color-black);
.MuiSlider-rail { .MuiSlider-rail {
color: var(--text-color-black); color: var(--text-color-black);
} }
} }
.loadCircle { .loadCircle {
svg { svg {
path { path {
stroke: var(--text-color-black); stroke: var(--text-color-black);
} }
} }
} }
} }
} }
.toolbar_player_top_actions { .toolbar_player_top_actions {
position: relative; position: relative;
z-index: 60; z-index: 60;
display: inline-flex; display: inline-flex;
flex-direction: row; flex-direction: row;
width: 100%; width: 100%;
height: 0px; height: 0px;
gap: 20px; gap: 20px;
transition: all 150ms ease-in-out; transition: all 150ms ease-in-out;
transform: translateY(calc(@toolbar_player_borderRadius / 2)); transform: translateY(calc(@toolbar_player_borderRadius / 2));
overflow: hidden; overflow: hidden;
background-color: var(--background-color-primary); background-color: var(--background-color-primary);
border-radius: 12px 12px 0 0; border-radius: 12px 12px 0 0;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
} }
.toolbar_player { .toolbar_player {
position: relative; position: relative;
display: flex; display: flex;
width: 100%; width: 100%;
height: 200px; height: 200px;
overflow: hidden; overflow: hidden;
z-index: 70; z-index: 70;
border-radius: @toolbar_player_borderRadius; border-radius: @toolbar_player_borderRadius;
.toolbar_cover_background { .toolbar_cover_background {
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
background-position: center; background-position: center;
background-repeat: no-repeat; background-repeat: no-repeat;
background-size: cover; background-size: cover;
transition: all 0.3s ease-in-out; transition: all 0.3s ease-in-out;
opacity: 0.6; opacity: 0.6;
z-index: 65; z-index: 65;
// create a mask to the bottom // create a mask to the bottom
//-webkit-mask-image: linear-gradient(to top, rgba(0, 0, 0, 0), rgba(0, 0, 0, 1)); //-webkit-mask-image: linear-gradient(to top, rgba(0, 0, 0, 0), rgba(0, 0, 0, 1));
} }
.toolbar_player_content { .toolbar_player_content {
position: relative; position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: 100%; width: 100%;
height: 100%; height: 100%;
padding: 10px; padding: 10px;
z-index: 80; z-index: 80;
justify-content: space-between; justify-content: space-between;
background-color: rgba(var(--cover_averageValues), 0.7); background-color: rgba(var(--cover_averageValues), 0.7);
color: var(--text-color-white); color: var(--text-color-white);
.toolbar_player_info { .toolbar_player_info {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
color: currentColor; color: currentColor;
.toolbar_player_info_title { .toolbar_player_info_title {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
margin: 0; margin: 0;
margin-right: 30px; margin-right: 30px;
width: fit-content; width: fit-content;
overflow: hidden; overflow: hidden;
font-size: 1.5rem; font-size: 1.5rem;
font-weight: 600; font-weight: 600;
font-family: "Space Grotesk", sans-serif; font-family: "Space Grotesk", sans-serif;
transition: all 150ms ease-in-out; transition: all 150ms ease-in-out;
color: currentColor; color: currentColor;
word-break: break-all; word-break: break-all;
white-space: nowrap; white-space: nowrap;
&.overflown { &.overflown {
opacity: 0; opacity: 0;
height: 0; height: 0;
} }
} }
.toolbar_player_info_subtitle { .toolbar_player_info_subtitle {
font-size: 0.8rem; font-size: 0.8rem;
font-weight: 400; font-weight: 400;
color: currentColor; color: currentColor;
} }
} }
.toolbar_player_actions { .toolbar_player_actions {
position: absolute; position: absolute;
color: currentColor; color: currentColor;
bottom: 0; bottom: 0;
left: 0; left: 0;
height: fit-content; height: fit-content;
width: 100%; width: 100%;
padding: 10px; padding: 10px;
gap: 5px; gap: 5px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
.player-controls { .player-controls {
color: currentColor; color: currentColor;
transition: all 150ms ease-in-out; transition: all 150ms ease-in-out;
// padding: 3px 0; // padding: 3px 0;
// background-color: rgba(var(--layoutBackgroundColor), 0.7); // background-color: rgba(var(--layoutBackgroundColor), 0.7);
// -webkit-backdrop-filter: blur(5px); // -webkit-backdrop-filter: blur(5px);
// backdrop-filter: blur(5px); // backdrop-filter: blur(5px);
// border-radius: 12px; // border-radius: 12px;
.ant-btn-icon, .ant-btn-icon,
button { button {
color: currentColor; color: currentColor;
svg { svg {
color: currentColor; color: currentColor;
} }
} }
} }
.player-seek_bar { .player-seek_bar {
height: fit-content; height: fit-content;
margin: 0; margin: 0;
color: currentColor; color: currentColor;
.timers { .timers {
color: currentColor; color: currentColor;
span { span {
color: currentColor; color: currentColor;
} }
} }
} }
} }
} }
}
.extra_actions {
display: flex;
flex-direction: row;
width: 70%;
margin: auto;
padding: 7px 25px;
justify-content: space-between;
background-color: rgba(var(--layoutBackgroundColor), 0.7);
-webkit-backdrop-filter: blur(5px);
backdrop-filter: blur(5px);
border-radius: 12px;
button,
.likeButton {
width: 32px;
height: 16px;
.ant-btn-icon {
svg {
width: 16px;
height: 16px;
}
}
}
.ant-btn {
padding: 0;
}
} }
.toolbar_player_indicators_wrapper { .toolbar_player_indicators_wrapper {
position: absolute; position: absolute;
bottom: 0; bottom: 0;
left: 0; left: 0;
padding: 10px; padding: 10px;
.toolbar_player_indicators {
display: flex;
flex-direction: row;
.toolbar_player_indicators { justify-content: space-between;
display: flex; align-items: center;
flex-direction: row;
justify-content: space-between; width: fit-content;
align-items: center;
width: fit-content; padding: 7px 10px;
padding: 7px 10px; border-radius: 12px;
border-radius: 12px; background-color: rgba(var(--layoutBackgroundColor), 0.7);
-webkit-backdrop-filter: blur(5px);
backdrop-filter: blur(5px);
background-color: rgba(var(--layoutBackgroundColor), 0.7); font-size: 1rem;
-webkit-backdrop-filter: blur(5px);
backdrop-filter: blur(5px);
font-size: 1rem; svg {
margin: 0 !important;
svg { color: white;
margin: 0 !important; }
}
color: white
}
}
} }

View File

@ -110,6 +110,7 @@ export class PostsListsComponent extends React.Component {
hasMore: true, hasMore: true,
list: this.props.list ?? [], list: this.props.list ?? [],
pageCount: 0,
} }
parentRef = this.props.innerRef parentRef = this.props.innerRef
@ -148,12 +149,17 @@ export class PostsListsComponent extends React.Component {
} }
handleLoad = async (fn, params = {}) => { handleLoad = async (fn, params = {}) => {
if (this.state.loading === true) {
console.warn(`Please wait to load the post before load more`)
return
}
this.setState({ this.setState({
loading: true, loading: true,
}) })
let payload = { let payload = {
trim: this.state.list.length, page: this.state.pageCount,
limit: app.cores.settings.get("feed_max_fetch"), limit: app.cores.settings.get("feed_max_fetch"),
} }
@ -164,10 +170,6 @@ export class PostsListsComponent extends React.Component {
} }
} }
if (params.replace) {
payload.trim = 0
}
const result = await fn(payload).catch((err) => { const result = await fn(payload).catch((err) => {
console.error(err) console.error(err)
@ -186,10 +188,12 @@ export class PostsListsComponent extends React.Component {
if (params.replace) { if (params.replace) {
this.setState({ this.setState({
list: result, list: result,
pageCount: 0,
}) })
} else { } else {
this.setState({ this.setState({
list: [...this.state.list, ...result], list: [...this.state.list, ...result],
pageCount: this.state.pageCount + 1,
}) })
} }
} }

View File

@ -7,112 +7,102 @@ import { Icons } from "@components/Icons"
import "./index.less" import "./index.less"
export default (props) => { export default (props) => {
const [uploading, setUploading] = React.useState(false) const [uploading, setUploading] = React.useState(false)
const [progess, setProgess] = React.useState(null) const [progress, setProgress] = React.useState(null)
const handleOnStart = (file_uid, file) => { const handleOnStart = (file_uid, file) => {
if (typeof props.onStart === "function") { if (typeof props.onStart === "function") {
props.onStart(file_uid, file) props.onStart(file_uid, file)
} }
} }
const handleOnProgress = (file_uid, progress) => { const handleOnProgress = (file_uid, progress) => {
if (typeof props.onProgress === "function") { if (typeof props.onProgress === "function") {
props.onProgress(file_uid, progress) props.onProgress(file_uid, progress)
} }
} }
const handleOnError = (file_uid, error) => { const handleOnError = (file_uid, error) => {
if (typeof props.onError === "function") { if (typeof props.onError === "function") {
props.onError(file_uid, error) props.onError(file_uid, error)
} }
} }
const handleOnSuccess = (file_uid, response) => { const handleOnSuccess = (file_uid, response) => {
if (typeof props.onSuccess === "function") { if (typeof props.onSuccess === "function") {
props.onSuccess(file_uid, response) props.onSuccess(file_uid, response)
} }
} }
const handleUpload = async (req) => { const handleUpload = async (req) => {
setUploading(true) setUploading(true)
setProgess(1) setProgress(1)
handleOnStart(req.file.uid, req.file) handleOnStart(req.file.uid, req.file)
await app.cores.remoteStorage.uploadFile(req.file, { await app.cores.remoteStorage.uploadFile(req.file, {
headers: props.headers, headers: props.headers,
onProgress: (file, progress) => { onProgress: (file, progress) => {
setProgess(progress) setProgress(progress)
handleOnProgress(file.uid, progress) handleOnProgress(file.uid, progress)
}, },
onError: (file, error) => { onError: (file, error) => {
setProgess(null) setProgress(null)
handleOnError(file.uid, error) handleOnError(file.uid, error)
setUploading(false) setUploading(false)
}, },
onFinish: (file, response) => { onFinish: (file, response) => {
if (typeof props.ctx?.onUpdateItem === "function") { if (typeof props.ctx?.onUpdateItem === "function") {
props.ctx.onUpdateItem(response.url) props.ctx.onUpdateItem(response.url)
} }
if (typeof props.onUploadDone === "function") { if (typeof props.onUploadDone === "function") {
props.onUploadDone(response) props.onUploadDone(response)
} }
setUploading(false) setUploading(false)
handleOnSuccess(req.file.uid, response) handleOnSuccess(req.file.uid, response)
setTimeout(() => { setTimeout(() => {
setProgess(null) setProgress(null)
}, 1000) }, 1000)
}, },
}) })
} }
return <Upload return (
customRequest={handleUpload} <Upload
multiple={ customRequest={handleUpload}
props.multiple ?? false multiple={props.multiple ?? false}
} accept={props.accept ?? ["image/*", "video/*", "audio/*"]}
accept={ progress={false}
props.accept ?? [ fileList={[]}
"image/*", className={classnames("uploadButton", {
"video/*", ["uploading"]: !!progress || uploading,
"audio/*", })}
] disabled={uploading}
} >
progress={false} <div className="uploadButton-content">
fileList={[]} {!progress &&
className={classnames( (props.icon ?? (
"uploadButton", <Icons.FiUpload
{ style={{
["uploading"]: !!progess || uploading margin: 0,
} }}
)} />
disabled={uploading} ))}
>
<div className="uploadButton-content">
{
!progess && (props.icon ?? <Icons.FiUpload
style={{
margin: 0
}}
/>)
}
{ {progress && (
progess && <Progress <Progress
type="circle" type="circle"
percent={progess} percent={progress?.percent ?? 0}
strokeWidth={20} strokeWidth={20}
format={() => null} format={() => null}
/> />
} )}
{ {props.children ?? "Upload"}
props.children ?? "Upload" </div>
} </Upload>
</div> )
</Upload>
} }

View File

@ -1,17 +1,19 @@
import React from "react" import React from "react"
export const DefaultReleaseEditorState = { export const DefaultReleaseEditorState = {
cover: null, cover: null,
title: "Untitled", title: "Untitled",
type: "single", type: "single",
public: false, public: false,
list: [], items: [],
pendingUploads: [], pendingUploads: [],
setCustomPage: () => {}, setCustomPage: () => {},
} }
export const ReleaseEditorStateContext = React.createContext(DefaultReleaseEditorState) export const ReleaseEditorStateContext = React.createContext(
DefaultReleaseEditorState,
)
export default ReleaseEditorStateContext export default ReleaseEditorStateContext

View File

@ -0,0 +1,123 @@
import { MediaPlayer } from "dashjs"
import PlayerProcessors from "./PlayerProcessors"
import AudioPlayerStorage from "../player.storage"
export default class AudioBase {
constructor(player) {
this.player = player
}
audio = new Audio()
context = null
demuxer = null
elementSource = null
processorsManager = new PlayerProcessors(this)
processors = {}
waitUpdateTimeout = null
initialize = async () => {
// create a audio context
this.context = new AudioContext({
sampleRate:
AudioPlayerStorage.get("sample_rate") ??
this.player.constructor.defaultSampleRate,
latencyHint: "playback",
})
// configure some settings for audio
this.audio.crossOrigin = "anonymous"
this.audio.preload = "metadata"
// listen all events
for (const [key, value] of Object.entries(this.audioEvents)) {
this.audio.addEventListener(key, value)
}
// setup demuxer for mpd
this.createDemuxer()
// create element source
this.elementSource = this.context.createMediaElementSource(this.audio)
// initialize audio processors
await this.processorsManager.initialize()
await this.processorsManager.attachAllNodes()
}
createDemuxer() {
this.demuxer = MediaPlayer().create()
this.demuxer.updateSettings({
streaming: {
buffer: {
resetSourceBuffersForTrackSwitch: true,
},
},
})
this.demuxer.initialize(this.audio, null, false)
}
flush() {
this.audio.pause()
this.audio.src = null
this.audio.currentTime = 0
this.demuxer.destroy()
this.createDemuxer()
}
audioEvents = {
ended: () => {
this.player.next()
},
loadeddata: () => {
this.player.state.loading = false
},
loadedmetadata: () => {
if (this.audio.duration === Infinity) {
this.player.state.live = true
} else {
this.player.state.live = false
}
},
play: () => {
this.player.state.playback_status = "playing"
},
playing: () => {
this.player.state.loading = false
this.player.state.playback_status = "playing"
if (typeof this.waitUpdateTimeout !== "undefined") {
clearTimeout(this.waitUpdateTimeout)
this.waitUpdateTimeout = null
}
},
pause: () => {
this.player.state.playback_status = "paused"
},
durationchange: () => {
this.player.eventBus.emit(
`player.durationchange`,
this.audio.duration,
)
},
waiting: () => {
if (this.waitUpdateTimeout) {
clearTimeout(this.waitUpdateTimeout)
this.waitUpdateTimeout = null
}
// if takes more than 150ms to load, update loading state
this.waitUpdateTimeout = setTimeout(() => {
this.player.state.loading = true
}, 150)
},
seeked: () => {
this.player.eventBus.emit(`player.seeked`, this.audio.currentTime)
},
}
}

View File

@ -0,0 +1,56 @@
export default class MediaSession {
constructor(player) {
this.player = player
}
async initialize() {
for (const [action, handler] of this.handlers) {
navigator.mediaSession.setActionHandler(action, handler)
}
}
handlers = [
[
"play",
() => {
console.log("media session play event", "play")
this.player.resumePlayback()
},
],
[
"pause",
() => {
console.log("media session pause event", "pause")
this.player.pausePlayback()
},
],
[
"seekto",
(seek) => {
console.log("media session seek event", seek)
this.player.seek(seek.seekTime)
},
],
]
update = (manifest) => {
navigator.mediaSession.metadata = new MediaMetadata({
title: manifest.title,
artist: manifest.artist,
album: manifest.album,
artwork: [
{
src: manifest.cover,
},
],
})
}
flush = () => {
navigator.mediaSession.metadata = null
}
updateIsPlaying = (isPlaying) => {
navigator.mediaSession.playbackState = isPlaying ? "playing" : "paused"
}
}

View File

@ -1,83 +1,96 @@
import defaultAudioProccessors from "../processors" import defaultAudioProccessors from "../processors"
export default class PlayerProcessors { export default class PlayerProcessors {
constructor(player) { constructor(base) {
this.player = player this.base = base
} }
processors = [] nodes = []
attached = []
public = {} public = {}
async initialize() { async initialize() {
// if already exists audio processors, destroy all before create new // if already exists audio processors, destroy all before create new
if (this.processors.length > 0) { if (this.nodes.length > 0) {
this.player.console.log("Destroying audio processors") this.base.player.console.log("Destroying audio processors")
this.processors.forEach((processor) => { this.nodes.forEach((node) => {
this.player.console.log(`Destroying audio processor ${processor.constructor.name}`, processor) this.base.player.console.log(
processor._destroy() `Destroying audio processor node ${node.constructor.name}`,
}) node,
)
node._destroy()
})
this.processors = [] this.nodes = []
} }
// instanciate default audio processors // instanciate default audio processors
for await (const defaultProccessor of defaultAudioProccessors) { for await (const defaultProccessor of defaultAudioProccessors) {
this.processors.push(new defaultProccessor(this.player)) this.nodes.push(new defaultProccessor(this))
} }
// initialize audio processors // initialize audio processors
for await (const processor of this.processors) { for await (const node of this.nodes) {
if (typeof processor._init === "function") { if (typeof node._init === "function") {
try { try {
await processor._init(this.player.audioContext) await node._init()
} catch (error) { } catch (error) {
this.player.console.error(`Failed to initialize audio processor ${processor.constructor.name} >`, error) this.base.player.console.error(
continue `Failed to initialize audio processor node ${node.constructor.name} >`,
} error,
} )
continue
}
}
// check if processor has exposed public methods // check if processor has exposed public methods
if (processor.exposeToPublic) { if (node.exposeToPublic) {
Object.entries(processor.exposeToPublic).forEach(([key, value]) => { Object.entries(node.exposeToPublic).forEach(([key, value]) => {
const refName = processor.constructor.refName const refName = node.constructor.refName
if (typeof this.player.public[refName] === "undefined") { if (typeof this.base.processors[refName] === "undefined") {
// by default create a empty object // by default create a empty object
this.player.public[refName] = {} this.base.processors[refName] = {}
} }
this.player.public[refName][key] = value this.base.processors[refName][key] = value
}) })
} }
} }
} }
async attachProcessorsToInstance(instance) { attachAllNodes = async () => {
for await (const [index, processor] of this.processors.entries()) { for await (const [index, node] of this.nodes.entries()) {
if (processor.constructor.node_bypass === true) { if (node.constructor.node_bypass === true) {
instance.contextElement.connect(processor.processor) this.base.context.elementSource.connect(node.processor)
processor.processor.connect(this.player.audioContext.destination) node.processor.connect(this.base.context.destination)
continue continue
} }
if (typeof processor._attach !== "function") { if (typeof node._attach !== "function") {
this.player.console.error(`Processor ${processor.constructor.refName} not support attach`) this.base.console.error(
`Processor ${node.constructor.refName} not support attach`,
)
continue continue
} }
instance = await processor._attach(instance, index) await node._attach(index)
} }
const lastProcessor = instance.attachedProcessors[instance.attachedProcessors.length - 1].processor const lastProcessor = this.attached[this.attached.length - 1].processor
// now attach to destination // now attach to destination
lastProcessor.connect(this.player.audioContext.destination) lastProcessor.connect(this.base.context.destination)
}
return instance detachAllNodes = async () => {
} for (const [index, node] of this.attached.entries()) {
await node._detach()
}
}
} }

View File

@ -1,206 +1,131 @@
import TrackManifest from "./TrackManifest" import TrackManifest from "./TrackManifest"
import { MediaPlayer } from "dashjs"
export default class TrackInstance { export default class TrackInstance {
constructor(player, manifest) { constructor(manifest, player) {
if (typeof manifest === "undefined") {
throw new Error("Manifest is required")
}
if (!player) { if (!player) {
throw new Error("Player core is required") throw new Error("Player core is required")
} }
if (typeof manifest === "undefined") { if (!(manifest instanceof TrackManifest)) {
throw new Error("Manifest is required") manifest = new TrackManifest(manifest, player)
}
if (!manifest.source) {
throw new Error("Manifest must have a source")
} }
this.player = player this.player = player
this.manifest = manifest this.manifest = manifest
this.id = this.manifest.id ?? this.manifest._id this.id = this.manifest.id ?? this.manifest._id
return this
} }
_initialized = false play = async (params = {}) => {
const startTime = performance.now()
audio = null if (!this.manifest.source.endsWith(".mpd")) {
this.player.base.demuxer.destroy()
contextElement = null this.player.base.audio.src = this.manifest.source
abortController = new AbortController()
attachedProcessors = []
waitUpdateTimeout = null
mediaEvents = {
ended: () => {
this.player.next()
},
loadeddata: () => {
this.player.state.loading = false
},
loadedmetadata: () => {
if (this.audio.duration === Infinity) {
this.player.state.live = true
} else {
this.player.state.live = false
}
},
play: () => {
this.player.state.playback_status = "playing"
},
playing: () => {
this.player.state.loading = false
this.player.state.playback_status = "playing"
if (typeof this.waitUpdateTimeout !== "undefined") {
clearTimeout(this.waitUpdateTimeout)
this.waitUpdateTimeout = null
}
},
pause: () => {
this.player.state.playback_status = "paused"
},
durationchange: () => {
this.player.eventBus.emit(
`player.durationchange`,
this.audio.duration,
)
},
waiting: () => {
if (this.waitUpdateTimeout) {
clearTimeout(this.waitUpdateTimeout)
this.waitUpdateTimeout = null
}
// if takes more than 150ms to load, update loading state
this.waitUpdateTimeout = setTimeout(() => {
this.player.state.loading = true
}, 150)
},
seeked: () => {
this.player.eventBus.emit(`player.seeked`, this.audio.currentTime)
},
}
initialize = async () => {
this.manifest = await this.resolveManifest()
this.audio = new Audio()
this.audio.signal = this.abortController.signal
this.audio.crossOrigin = "anonymous"
this.audio.preload = "metadata"
// support for dash audio streaming
if (this.manifest.source.endsWith(".mpd")) {
this.muxerPlayer = MediaPlayer().create()
this.muxerPlayer.updateSettings({
streaming: {
buffer: {
resetSourceBuffersForTrackSwitch: true,
useChangeTypeForTrackSwitch: false,
},
},
})
this.muxerPlayer.initialize(this.audio, null, false)
this.muxerPlayer.attachSource(this.manifest.source)
} else { } else {
this.audio.src = this.manifest.source if (!this.player.base.demuxer) {
} this.player.base.createDemuxer()
for (const [key, value] of Object.entries(this.mediaEvents)) {
this.audio.addEventListener(key, value)
}
this.contextElement = this.player.audioContext.createMediaElementSource(
this.audio,
)
this._initialized = true
return this
}
stop = () => {
if (this.audio) {
this.audio.pause()
}
if (this.muxerPlayer) {
this.muxerPlayer.destroy()
}
const lastProcessor =
this.attachedProcessors[this.attachedProcessors.length - 1]
if (lastProcessor) {
this.attachedProcessors[
this.attachedProcessors.length - 1
]._destroy(this)
}
this.attachedProcessors = []
}
resolveManifest = async () => {
if (typeof this.manifest === "string") {
this.manifest = {
src: this.manifest,
}
}
this.manifest = new TrackManifest(this.manifest, {
serviceProviders: this.player.serviceProviders,
})
if (this.manifest.service) {
if (!this.player.serviceProviders.has(this.manifest.service)) {
throw new Error(
`Service ${this.manifest.service} is not supported`,
)
} }
// try to resolve source file await this.player.base.demuxer.attachSource(
if (!this.manifest.source) { `${this.manifest.source}?t=${Date.now()}`,
console.log("Resolving manifest cause no source defined")
this.manifest = await this.player.serviceProviders.resolve(
this.manifest.service,
this.manifest,
)
console.log("Manifest resolved", this.manifest)
}
}
if (!this.manifest.source) {
throw new Error("Manifest `source` is required")
}
// set empty metadata if not provided
if (!this.manifest.metadata) {
this.manifest.metadata = {}
}
// auto name if a title is not provided
if (!this.manifest.metadata.title) {
this.manifest.metadata.title = this.manifest.source.split("/").pop()
}
// process overrides
const override = await this.manifest.serviceOperations.fetchOverride()
if (override) {
console.log(
`Override found for track ${this.manifest._id}`,
override,
) )
this.manifest.overrides = override
} }
return this.manifest this.player.base.audio.currentTime = params.time ?? 0
if (this.player.base.audio.paused) {
await this.player.base.audio.play()
}
// reset audio volume and gain
this.player.base.audio.volume = 1
this.player.base.processors.gain.set(this.player.state.volume)
const endTime = performance.now()
this._loadMs = endTime - startTime
console.log(`[INSTANCE] Playing >`, this)
} }
pause = async () => {
console.log("[INSTANCE] Pausing >", this)
this.player.base.audio.pause()
}
resume = async () => {
console.log("[INSTANCE] Resuming >", this)
this.player.base.audio.play()
}
// resolveManifest = async () => {
// if (typeof this.manifest === "string") {
// this.manifest = {
// src: this.manifest,
// }
// }
// this.manifest = new TrackManifest(this.manifest, {
// serviceProviders: this.player.serviceProviders,
// })
// if (this.manifest.service) {
// if (!this.player.serviceProviders.has(this.manifest.service)) {
// throw new Error(
// `Service ${this.manifest.service} is not supported`,
// )
// }
// // try to resolve source file
// if (!this.manifest.source) {
// console.log("Resolving manifest cause no source defined")
// this.manifest = await this.player.serviceProviders.resolve(
// this.manifest.service,
// this.manifest,
// )
// console.log("Manifest resolved", this.manifest)
// }
// }
// if (!this.manifest.source) {
// throw new Error("Manifest `source` is required")
// }
// // set empty metadata if not provided
// if (!this.manifest.metadata) {
// this.manifest.metadata = {}
// }
// // auto name if a title is not provided
// if (!this.manifest.metadata.title) {
// this.manifest.metadata.title = this.manifest.source.split("/").pop()
// }
// // process overrides
// const override = await this.manifest.serviceOperations.fetchOverride()
// if (override) {
// console.log(
// `Override found for track ${this.manifest._id}`,
// override,
// )
// this.manifest.overrides = override
// }
// return this.manifest
// }
} }

View File

@ -1,4 +1,4 @@
import jsmediatags from "jsmediatags/dist/jsmediatags.min.js" import { parseBlob } from "music-metadata"
import { FastAverageColor } from "fast-average-color" import { FastAverageColor } from "fast-average-color"
export default class TrackManifest { export default class TrackManifest {
@ -33,13 +33,6 @@ export default class TrackManifest {
this.artist = params.artist this.artist = params.artist
} }
if (
typeof params.artists !== "undefined" ||
Array.isArray(params.artists)
) {
this.artistStr = params.artists.join(", ")
}
if (typeof params.source !== "undefined") { if (typeof params.source !== "undefined") {
this.source = params.source this.source = params.source
} }
@ -48,8 +41,8 @@ export default class TrackManifest {
this.metadata = params.metadata this.metadata = params.metadata
} }
if (typeof params.lyrics_enabled !== "undefined") { if (typeof params.liked !== "undefined") {
this.lyrics_enabled = params.lyrics_enabled this.liked = params.liked
} }
return this return this
@ -58,87 +51,45 @@ export default class TrackManifest {
_id = null // used for api requests _id = null // used for api requests
uid = null // used for internal uid = null // used for internal
cover =
"https://storage.ragestudio.net/comty-static-assets/default_song.png"
title = "Untitled" title = "Untitled"
album = "Unknown" album = "Unknown"
artist = "Unknown" artist = "Unknown"
cover = null // set default cover url
source = null source = null
metadata = null metadata = {}
// set default service to default // set default service to default
service = "default" service = "default"
// Extended from db
lyrics_enabled = false
liked = null
async initialize() { async initialize() {
if (this.params.file) { if (!this.params.file) {
this.metadata = await this.analyzeMetadata( return this
this.params.file.originFileObj, }
)
this.metadata.format = this.metadata.type.toUpperCase() const analyzedMetadata = await parseBlob(this.params.file, {
skipPostHeaders: true,
}).catch(() => ({}))
if (this.metadata.tags) { if (analyzedMetadata.format) {
if (this.metadata.tags.title) { this.metadata.format = analyzedMetadata.format.codec
this.title = this.metadata.tags.title }
}
if (this.metadata.tags.artist) { if (analyzedMetadata.common) {
this.artist = this.metadata.tags.artist this.title = analyzedMetadata.common.title ?? this.title
} this.artist = analyzedMetadata.common.artist ?? this.artist
this.album = analyzedMetadata.common.album ?? this.album
}
if (this.metadata.tags.album) { if (analyzedMetadata.common.picture) {
this.album = this.metadata.tags.album const cover = analyzedMetadata.common.picture[0]
}
if (this.metadata.tags.picture) { this._coverBlob = new Blob([cover.data], { type: cover.format })
this.cover = app.cores.remoteStorage.binaryArrayToFile( this.cover = URL.createObjectURL(this._coverBlob)
this.metadata.tags.picture,
"cover",
)
const coverUpload =
await app.cores.remoteStorage.uploadFile(this.cover)
this.cover = coverUpload.url
delete this.metadata.tags.picture
}
this.handleChanges({
cover: this.cover,
title: this.title,
artist: this.artist,
album: this.album,
})
}
} }
return this return this
} }
handleChanges = (changes) => {
if (typeof this.params.onChange === "function") {
this.params.onChange(this.uid, changes)
}
}
analyzeMetadata = async (file) => {
return new Promise((resolve, reject) => {
jsmediatags.read(file, {
onSuccess: (data) => {
return resolve(data)
},
onError: (error) => {
return reject(error)
},
})
})
}
analyzeCoverColor = async () => { analyzeCoverColor = async () => {
const fac = new FastAverageColor() const fac = new FastAverageColor()
@ -169,8 +120,6 @@ export default class TrackManifest {
this, this,
) )
console.log(this.overrides)
if (this.overrides) { if (this.overrides) {
return { return {
...result, ...result,
@ -210,6 +159,7 @@ export default class TrackManifest {
return { return {
_id: this._id, _id: this._id,
uid: this.uid, uid: this.uid,
cover: this.cover,
title: this.title, title: this.title,
album: this.album, album: this.album,
artist: this.artist, artist: this.artist,

View File

@ -3,11 +3,11 @@ import { Core } from "@ragestudio/vessel"
import ActivityEvent from "@classes/ActivityEvent" import ActivityEvent from "@classes/ActivityEvent"
import QueueManager from "@classes/QueueManager" import QueueManager from "@classes/QueueManager"
import TrackInstance from "./classes/TrackInstance" import TrackInstance from "./classes/TrackInstance"
//import MediaSession from "./classes/MediaSession" import MediaSession from "./classes/MediaSession"
import ServiceProviders from "./classes/Services" import ServiceProviders from "./classes/Services"
import PlayerState from "./classes/PlayerState" import PlayerState from "./classes/PlayerState"
import PlayerUI from "./classes/PlayerUI" import PlayerUI from "./classes/PlayerUI"
import PlayerProcessors from "./classes/PlayerProcessors" import AudioBase from "./classes/AudioBase"
import setSampleRate from "./helpers/setSampleRate" import setSampleRate from "./helpers/setSampleRate"
@ -22,27 +22,18 @@ export default class Player extends Core {
// player config // player config
static defaultSampleRate = 48000 static defaultSampleRate = 48000
static gradualFadeMs = 150
static maxManifestPrecompute = 3
state = new PlayerState(this) state = new PlayerState(this)
ui = new PlayerUI(this) ui = new PlayerUI(this)
serviceProviders = new ServiceProviders() serviceProviders = new ServiceProviders()
//nativeControls = new MediaSession() nativeControls = new MediaSession(this)
audioContext = new AudioContext({
sampleRate:
AudioPlayerStorage.get("sample_rate") ?? Player.defaultSampleRate,
latencyHint: "playback",
})
audioProcessors = new PlayerProcessors(this) base = new AudioBase(this)
queue = new QueueManager({ queue = new QueueManager({
loadFunction: this.createInstance, loadFunction: this.createInstance,
}) })
currentTrackInstance = null
public = { public = {
start: this.start, start: this.start,
close: this.close, close: this.close,
@ -74,10 +65,11 @@ export default class Player extends Core {
eventBus: () => { eventBus: () => {
return this.eventBus return this.eventBus
}, },
base: () => {
return this.base
},
state: this.state, state: this.state,
ui: this.ui.public, ui: this.ui.public,
audioContext: this.audioContext,
gradualFadeMs: Player.gradualFadeMs,
} }
async afterInitialize() { async afterInitialize() {
@ -85,8 +77,8 @@ export default class Player extends Core {
this.state.volume = 1 this.state.volume = 1
} }
//await this.nativeControls.initialize() await this.nativeControls.initialize()
await this.audioProcessors.initialize() await this.base.initialize()
} }
// //
@ -100,10 +92,6 @@ export default class Player extends Core {
} }
} }
async createInstance(manifest) {
return new TrackInstance(this, manifest)
}
// //
// Playback methods // Playback methods
// //
@ -112,46 +100,21 @@ export default class Player extends Core {
throw new Error("Audio instance is required") throw new Error("Audio instance is required")
} }
this.console.log("Initializing instance", instance)
// resume audio context if needed // resume audio context if needed
if (this.audioContext.state === "suspended") { if (this.base.context.state === "suspended") {
this.audioContext.resume() this.base.context.resume()
} }
// initialize instance if is not
if (this.queue.currentItem._initialized === false) {
this.queue.currentItem = await instance.initialize()
}
this.console.log("Instance", this.queue.currentItem)
// update manifest // update manifest
this.state.track_manifest = this.queue.currentItem.manifest this.state.track_manifest =
this.queue.currentItem.manifest.toSeriableObject()
// attach processors
this.queue.currentItem =
await this.audioProcessors.attachProcessorsToInstance(
this.queue.currentItem,
)
// set audio properties
this.queue.currentItem.audio.currentTime = params.time ?? 0
this.queue.currentItem.audio.muted = this.state.muted
this.queue.currentItem.audio.loop =
this.state.playback_mode === "repeat"
this.queue.currentItem.gainNode.gain.value = Math.pow(
this.state.volume,
2,
)
// play // play
await this.queue.currentItem.audio.play() //await this.queue.currentItem.audio.play()
await this.queue.currentItem.play(params)
this.console.log(`Playing track >`, this.queue.currentItem)
// update native controls // update native controls
//this.nativeControls.update(this.queue.currentItem.manifest) this.nativeControls.update(this.queue.currentItem.manifest)
return this.queue.currentItem return this.queue.currentItem
} }
@ -160,10 +123,10 @@ export default class Player extends Core {
this.ui.attachPlayerComponent() this.ui.attachPlayerComponent()
if (this.queue.currentItem) { if (this.queue.currentItem) {
await this.queue.currentItem.stop() await this.queue.currentItem.pause()
} }
await this.abortPreloads() //await this.abortPreloads()
await this.queue.flush() await this.queue.flush()
this.state.loading = true this.state.loading = true
@ -187,8 +150,8 @@ export default class Player extends Core {
playlist = await this.serviceProviders.resolveMany(playlist) playlist = await this.serviceProviders.resolveMany(playlist)
} }
for await (const [index, _manifest] of playlist.entries()) { for await (let [index, _manifest] of playlist.entries()) {
let instance = await this.createInstance(_manifest) let instance = new TrackInstance(_manifest, this)
this.queue.add(instance) this.queue.add(instance)
} }
@ -229,10 +192,6 @@ export default class Player extends Core {
} }
next() { next() {
if (this.queue.currentItem) {
this.queue.currentItem.stop()
}
//const isRandom = this.state.playback_mode === "shuffle" //const isRandom = this.state.playback_mode === "shuffle"
const item = this.queue.next() const item = this.queue.next()
@ -244,10 +203,6 @@ export default class Player extends Core {
} }
previous() { previous() {
if (this.queue.currentItem) {
this.queue.currentItem.stop()
}
const item = this.queue.previous() const item = this.queue.previous()
return this.play(item) return this.play(item)
@ -275,18 +230,14 @@ export default class Player extends Core {
return null return null
} }
// set gain exponentially this.base.processors.gain.fade(0)
this.queue.currentItem.gainNode.gain.linearRampToValueAtTime(
0.0001,
this.audioContext.currentTime + Player.gradualFadeMs / 1000,
)
setTimeout(() => { setTimeout(() => {
this.queue.currentItem.audio.pause() this.queue.currentItem.pause()
resolve() resolve()
}, Player.gradualFadeMs) }, Player.gradualFadeMs)
//this.nativeControls.updateIsPlaying(false) this.nativeControls.updateIsPlaying(false)
}) })
} }
@ -302,19 +253,12 @@ export default class Player extends Core {
} }
// ensure audio elemeto starts from 0 volume // ensure audio elemeto starts from 0 volume
this.queue.currentItem.gainNode.gain.value = 0.0001 this.queue.currentItem.resume().then(() => {
this.queue.currentItem.audio.play().then(() => {
resolve() resolve()
}) })
this.base.processors.gain.fade(this.state.volume)
// set gain exponentially this.nativeControls.updateIsPlaying(true)
this.queue.currentItem.gainNode.gain.linearRampToValueAtTime(
Math.pow(this.state.volume, 2),
this.audioContext.currentTime + Player.gradualFadeMs / 1000,
)
//this.nativeControls.updateIsPlaying(true)
}) })
} }
@ -325,10 +269,7 @@ export default class Player extends Core {
this.state.playback_mode = mode this.state.playback_mode = mode
if (this.queue.currentItem) { this.base.audio.loop = this.state.playback_mode === "repeat"
this.queue.currentItem.audio.loop =
this.state.playback_mode === "repeat"
}
AudioPlayerStorage.set("mode", mode) AudioPlayerStorage.set("mode", mode)
@ -336,22 +277,15 @@ export default class Player extends Core {
} }
stopPlayback() { stopPlayback() {
if (this.queue.currentItem) { this.base.flush()
this.queue.currentItem.stop()
}
this.queue.flush() this.queue.flush()
this.abortPreloads()
this.state.playback_status = "stopped" this.state.playback_status = "stopped"
this.state.track_manifest = null this.state.track_manifest = null
this.queue.currentItem = null this.queue.currentItem = null
this.track_next_instances = []
this.track_prev_instances = []
//this.nativeControls.destroy() //this.abortPreloads()
this.nativeControls.flush()
} }
// //
@ -369,7 +303,7 @@ export default class Player extends Core {
if (typeof to === "boolean") { if (typeof to === "boolean") {
this.state.muted = to this.state.muted = to
this.queue.currentItem.audio.muted = to this.base.audio.muted = to
} }
return this.state.muted return this.state.muted
@ -395,65 +329,42 @@ export default class Player extends Core {
volume = 0 volume = 0
} }
this.state.volume = volume
AudioPlayerStorage.set("volume", volume) AudioPlayerStorage.set("volume", volume)
if (this.queue.currentItem) { this.state.volume = volume
if (this.queue.currentItem.gainNode) { this.base.processors.gain.set(volume)
this.queue.currentItem.gainNode.gain.value = Math.pow(
this.state.volume,
2,
)
}
}
return this.state.volume return this.state.volume
} }
seek(time) { seek(time) {
if (!this.queue.currentItem || !this.queue.currentItem.audio) { if (!this.base.audio) {
return false return false
} }
// if time not provided, return current time // if time not provided, return current time
if (typeof time === "undefined") { if (typeof time === "undefined") {
return this.queue.currentItem.audio.currentTime return this.base.audio.currentTime
} }
// if time is provided, seek to that time // if time is provided, seek to that time
if (typeof time === "number") { if (typeof time === "number") {
this.console.log( this.console.log(
`Seeking to ${time} | Duration: ${this.queue.currentItem.audio.duration}`, `Seeking to ${time} | Duration: ${this.base.audio.duration}`,
) )
this.queue.currentItem.audio.currentTime = time this.base.audio.currentTime = time
return time return time
} }
} }
duration() { duration() {
if (!this.queue.currentItem || !this.queue.currentItem.audio) { if (!this.base.audio) {
return false return false
} }
return this.queue.currentItem.audio.duration return this.base.audio.duration
}
loop(to) {
if (typeof to !== "boolean") {
this.console.warn("Loop must be a boolean")
return false
}
this.state.loop = to ?? !this.state.loop
if (this.queue.currentItem.audio) {
this.queue.currentItem.audio.loop = this.state.loop
}
return this.state.loop
} }
close() { close() {

View File

@ -2,44 +2,40 @@ import ProcessorNode from "../node"
import Presets from "../../classes/Presets" import Presets from "../../classes/Presets"
export default class CompressorProcessorNode extends ProcessorNode { export default class CompressorProcessorNode extends ProcessorNode {
constructor(props) { constructor(props) {
super(props) super(props)
this.presets = new Presets({ this.presets = new Presets({
storage_key: "compressor", storage_key: "compressor",
defaultPresetValue: { defaultPresetValue: {
threshold: -50, threshold: -50,
knee: 40, knee: 40,
ratio: 12, ratio: 12,
attack: 0.003, attack: 0.003,
release: 0.25, release: 0.25,
}, },
onApplyValues: this.applyValues.bind(this), onApplyValues: this.applyValues.bind(this),
}) })
this.exposeToPublic = { this.exposeToPublic = {
presets: this.presets, presets: this.presets,
detach: this._detach, detach: this._detach,
attach: this._attach, attach: this._attach,
} }
} }
static refName = "compressor" static refName = "compressor"
static dependsOnSettings = ["player.compressor"] static dependsOnSettings = ["player.compressor"]
async init(AudioContext) { async init() {
if (!AudioContext) { this.processor = this.audioContext.createDynamicsCompressor()
throw new Error("AudioContext is required")
}
this.processor = AudioContext.createDynamicsCompressor() this.applyValues()
}
this.applyValues() applyValues() {
} Object.keys(this.presets.currentPresetValues).forEach((key) => {
this.processor[key].value = this.presets.currentPresetValues[key]
applyValues() { })
Object.keys(this.presets.currentPresetValues).forEach((key) => { }
this.processor[key].value = this.presets.currentPresetValues[key]
})
}
} }

View File

@ -2,93 +2,98 @@ import ProcessorNode from "../node"
import Presets from "../../classes/Presets" import Presets from "../../classes/Presets"
export default class EqProcessorNode extends ProcessorNode { export default class EqProcessorNode extends ProcessorNode {
constructor(props) { constructor(props) {
super(props) super(props)
this.presets = new Presets({ this.presets = new Presets({
storage_key: "eq", storage_key: "eq",
defaultPresetValue: { defaultPresetValue: {
32: 0, 32: 0,
64: 0, 64: 0,
125: 0, 125: 0,
250: 0, 250: 0,
500: 0, 500: 0,
1000: 0, 1000: 0,
2000: 0, 2000: 0,
4000: 0, 4000: 0,
8000: 0, 8000: 0,
16000: 0, 16000: 0,
}, },
onApplyValues: this.applyValues.bind(this), onApplyValues: this.applyValues.bind(this),
}) })
this.exposeToPublic = { this.exposeToPublic = {
presets: this.presets, presets: this.presets,
} }
} }
static refName = "eq" static refName = "eq"
static lock = true
applyValues() { applyValues() {
// apply to current instance // apply to current instance
this.processor.eqNodes.forEach((processor) => { this.processor.eqNodes.forEach((processor) => {
const gainValue = this.presets.currentPresetValues[processor.frequency.value] const gainValue =
this.presets.currentPresetValues[processor.frequency.value]
if (processor.gain.value !== gainValue) { if (processor.gain.value !== gainValue) {
console.debug(`[EQ] Applying values to ${processor.frequency.value} Hz frequency with gain ${gainValue}`) console.debug(
processor.gain.value = gainValue `[EQ] Applying values to ${processor.frequency.value} Hz frequency with gain ${gainValue}`,
} )
}) processor.gain.value = gainValue
} }
})
}
async init() { async init() {
if (!this.audioContext) { if (!this.audioContext) {
throw new Error("audioContext is required") throw new Error("audioContext is required")
} }
this.processor = this.audioContext.createGain() this.processor = this.audioContext.createGain()
this.processor.gain.value = 1 this.processor.gain.value = 1
this.processor.eqNodes = [] this.processor.eqNodes = []
const values = Object.entries(this.presets.currentPresetValues).map((entry) => { const values = Object.entries(this.presets.currentPresetValues).map(
return { (entry) => {
freq: parseFloat(entry[0]), return {
gain: parseFloat(entry[1]), freq: parseFloat(entry[0]),
} gain: parseFloat(entry[1]),
}) }
},
)
values.forEach((eqValue, index) => { values.forEach((eqValue, index) => {
// chekc if freq and gain is valid // chekc if freq and gain is valid
if (isNaN(eqValue.freq)) { if (isNaN(eqValue.freq)) {
eqValue.freq = 0 eqValue.freq = 0
} }
if (isNaN(eqValue.gain)) { if (isNaN(eqValue.gain)) {
eqValue.gain = 0 eqValue.gain = 0
} }
this.processor.eqNodes[index] = this.audioContext.createBiquadFilter() this.processor.eqNodes[index] =
this.processor.eqNodes[index].type = "peaking" this.audioContext.createBiquadFilter()
this.processor.eqNodes[index].frequency.value = eqValue.freq this.processor.eqNodes[index].type = "peaking"
this.processor.eqNodes[index].gain.value = eqValue.gain this.processor.eqNodes[index].frequency.value = eqValue.freq
}) this.processor.eqNodes[index].gain.value = eqValue.gain
})
// connect nodes // connect nodes
for await (let [index, eqNode] of this.processor.eqNodes.entries()) { for await (let [index, eqNode] of this.processor.eqNodes.entries()) {
const nextNode = this.processor.eqNodes[index + 1] const nextNode = this.processor.eqNodes[index + 1]
if (index === 0) { if (index === 0) {
this.processor.connect(eqNode) this.processor.connect(eqNode)
} }
if (nextNode) { if (nextNode) {
eqNode.connect(nextNode) eqNode.connect(nextNode)
} }
} }
// set last processor for processor node can properly connect to the next node // set last processor for processor node can properly connect to the next node
this.processor._last = this.processor.eqNodes.at(-1) this.processor._last = this.processor.eqNodes.at(-1)
} }
} }

View File

@ -1,60 +1,49 @@
import AudioPlayerStorage from "../../player.storage"
import ProcessorNode from "../node" import ProcessorNode from "../node"
export default class GainProcessorNode extends ProcessorNode { export default class GainProcessorNode extends ProcessorNode {
static refName = "gain" static refName = "gain"
static gradualFadeMs = 150
static lock = true exposeToPublic = {
set: this.setGain.bind(this),
linearRampToValueAtTime: this.linearRampToValueAtTime.bind(this),
fade: this.fade.bind(this),
}
static defaultValues = { setGain(gain) {
gain: 1, gain = this.processGainValue(gain)
}
state = { return (this.processor.gain.value = gain)
gain: AudioPlayerStorage.get("gain") ?? GainProcessorNode.defaultValues.gain, }
}
exposeToPublic = { linearRampToValueAtTime(gain, time) {
modifyValues: function (values) { gain = this.processGainValue(gain)
this.state = { return this.processor.gain.linearRampToValueAtTime(gain, time)
...this.state, }
...values,
}
AudioPlayerStorage.set("gain", this.state.gain) fade(gain) {
if (gain <= 0) {
gain = 0.0001
} else {
gain = this.processGainValue(gain)
}
this.applyValues() const currentTime = this.audioContext.currentTime
}.bind(this), const fadeTime = currentTime + this.constructor.gradualFadeMs / 1000
resetDefaultValues: function () {
this.exposeToPublic.modifyValues(GainProcessorNode.defaultValues)
return this.state this.processor.gain.linearRampToValueAtTime(gain, fadeTime)
}.bind(this), }
values: () => this.state,
}
applyValues() { processGainValue(gain) {
// apply to current instance return Math.pow(gain, 2)
this.processor.gain.value = app.cores.player.state.volume * this.state.gain }
}
async init() { async init() {
if (!this.audioContext) { if (!this.audioContext) {
throw new Error("audioContext is required") throw new Error("audioContext is required")
} }
this.processor = this.audioContext.createGain() this.processor = this.audioContext.createGain()
this.processor.gain.value = this.player.state.volume
this.applyValues() }
}
mutateInstance(instance) {
if (!instance) {
throw new Error("instance is required")
}
instance.gainNode = this.processor
return instance
}
} }

View File

@ -2,13 +2,12 @@ import EqProcessorNode from "./eqNode"
import GainProcessorNode from "./gainNode" import GainProcessorNode from "./gainNode"
import CompressorProcessorNode from "./compressorNode" import CompressorProcessorNode from "./compressorNode"
//import BPMProcessorNode from "./bpmNode" //import BPMProcessorNode from "./bpmNode"
//import SpatialNode from "./spatialNode"
import SpatialNode from "./spatialNode"
export default [ export default [
//BPMProcessorNode, //BPMProcessorNode,
EqProcessorNode, EqProcessorNode,
GainProcessorNode, GainProcessorNode,
CompressorProcessorNode, CompressorProcessorNode,
SpatialNode, //SpatialNode,
] ]

View File

@ -1,172 +1,147 @@
export default class ProcessorNode { export default class ProcessorNode {
constructor(PlayerCore) { constructor(manager) {
if (!PlayerCore) { if (!manager) {
throw new Error("PlayerCore is required") throw new Error("processorManager is required")
} }
this.PlayerCore = PlayerCore this.manager = manager
this.audioContext = PlayerCore.audioContext this.audioContext = manager.base.context
} this.elementSource = manager.base.elementSource
this.player = manager.base.player
}
async _init() { async _init() {
// check if has init method // check if has init method
if (typeof this.init === "function") { if (typeof this.init === "function") {
await this.init(this.audioContext) await this.init()
} }
// check if has declared bus events // check if has declared bus events
if (typeof this.busEvents === "object") { if (typeof this.busEvents === "object") {
Object.entries(this.busEvents).forEach((event, fn) => { Object.entries(this.busEvents).forEach((event, fn) => {
app.eventBus.on(event, fn) app.eventBus.on(event, fn)
}) })
} }
if (typeof this.processor._last === "undefined") { if (typeof this.processor._last === "undefined") {
this.processor._last = this.processor this.processor._last = this.processor
} }
return this return this
} }
_attach(instance, index) { _attach(index) {
if (typeof instance !== "object") { // check if has dependsOnSettings
instance = this.PlayerCore.currentAudioInstance if (Array.isArray(this.constructor.dependsOnSettings)) {
} // check if the instance has the settings
if (
!this.constructor.dependsOnSettings.every((setting) =>
app.cores.settings.get(setting),
)
) {
console.warn(
`Skipping attachment for [${this.constructor.refName ?? this.constructor.name}] node, cause is not passing the settings dependecy > ${this.constructor.dependsOnSettings.join(", ")}`,
)
// check if has dependsOnSettings return null
if (Array.isArray(this.constructor.dependsOnSettings)) { }
// check if the instance has the settings }
if (!this.constructor.dependsOnSettings.every((setting) => app.cores.settings.get(setting))) {
console.warn(`Skipping attachment for [${this.constructor.refName ?? this.constructor.name}] node, cause is not passing the settings dependecy > ${this.constructor.dependsOnSettings.join(", ")}`)
return instance // if index is not defined, attach to the last node
} if (!index) {
} index = this.manager.attached.length
}
// if index is not defined, attach to the last node const prevNode = this.manager.attached[index - 1]
if (!index) { const nextNode = this.manager.attached[index + 1]
index = instance.attachedProcessors.length
}
const prevNode = instance.attachedProcessors[index - 1] const currentIndex = this._findIndex()
const nextNode = instance.attachedProcessors[index + 1]
const currentIndex = this._findIndex(instance) // check if is already attached
if (currentIndex !== false) {
console.warn(
`[${this.constructor.refName ?? this.constructor.name}] node is already attached`,
)
// check if is already attached return null
if (currentIndex !== false) { }
console.warn(`[${this.constructor.refName ?? this.constructor.name}] node is already attached`)
return instance // first check if has prevNode and if is connected to something
} // if has, disconnect it
// if it not has, its means that is the first node, so connect to the media source
if (prevNode && prevNode.processor._last.numberOfOutputs > 0) {
//console.log(`[${this.constructor.refName ?? this.constructor.name}] node is already attached to the previous node, disconnecting...`)
// if has outputs, disconnect from the next node
prevNode.processor._last.disconnect()
// first check if has prevNode and if is connected to something // now, connect to the processor
// if has, disconnect it prevNode.processor._last.connect(this.processor)
// if it not has, its means that is the first node, so connect to the media source } else {
if (prevNode && prevNode.processor._last.numberOfOutputs > 0) { //console.log(`[${this.constructor.refName ?? this.constructor.name}] node is the first node, connecting to the media source...`)
//console.log(`[${this.constructor.refName ?? this.constructor.name}] node is already attached to the previous node, disconnecting...`) this.elementSource.connect(this.processor)
// if has outputs, disconnect from the next node }
prevNode.processor._last.disconnect()
// now, connect to the processor // now, check if it has a next node
prevNode.processor._last.connect(this.processor) // if has, connect to it
} else { // if not, connect to the destination
//console.log(`[${this.constructor.refName ?? this.constructor.name}] node is the first node, connecting to the media source...`) if (nextNode) {
instance.contextElement.connect(this.processor) this.processor.connect(nextNode.processor)
} }
// now, check if it has a next node // add to the attachedProcessors
// if has, connect to it this.manager.attached.splice(index, 0, this)
// if not, connect to the destination
if (nextNode) {
this.processor.connect(nextNode.processor)
}
// add to the attachedProcessors // // handle instance mutation
instance.attachedProcessors.splice(index, 0, this) // if (typeof this.mutateInstance === "function") {
// instance = this.mutateInstance(instance)
// }
// handle instance mutation return this
if (typeof this.mutateInstance === "function") { }
instance = this.mutateInstance(instance)
}
return instance _detach() {
} // find index of the node within the attachedProcessors serching for matching refName
const index = this._findIndex()
_detach(instance) { if (!index) {
if (typeof instance !== "object") { return null
instance = this.PlayerCore.currentAudioInstance }
}
// find index of the node within the attachedProcessors serching for matching refName // retrieve the previous and next nodes
const index = this._findIndex(instance) const prevNode = this.manager.attached[index - 1]
const nextNode = this.manager.attached[index + 1]
if (!index) { // check if has previous node and if has outputs
return instance if (prevNode && prevNode.processor._last.numberOfOutputs > 0) {
} // if has outputs, disconnect from the previous node
prevNode.processor._last.disconnect()
}
// retrieve the previous and next nodes // disconnect
const prevNode = instance.attachedProcessors[index - 1] this.processor.disconnect()
const nextNode = instance.attachedProcessors[index + 1] this.manager.attached.splice(index, 1)
// check if has previous node and if has outputs // now, connect the previous node to the next node
if (prevNode && prevNode.processor._last.numberOfOutputs > 0) { if (prevNode && nextNode) {
// if has outputs, disconnect from the previous node prevNode.processor._last.connect(nextNode.processor)
prevNode.processor._last.disconnect() } else {
} // it means that this is the last node, so connect to the destination
prevNode.processor._last.connect(this.audioContext.destination)
}
// disconnect return this
instance = this._destroy(instance) }
// now, connect the previous node to the next node _findIndex() {
if (prevNode && nextNode) { // find index of the node within the attachedProcessors serching for matching refName
prevNode.processor._last.connect(nextNode.processor) const index = this.manager.attached.findIndex((node) => {
} else { return node.constructor.refName === this.constructor.refName
// it means that this is the last node, so connect to the destination })
prevNode.processor._last.connect(this.audioContext.destination)
}
return instance if (index === -1) {
} return false
}
_destroy(instance) { return index
if (typeof instance !== "object") { }
instance = this.PlayerCore.currentAudioInstance
}
const index = this._findIndex(instance)
if (!index) {
return instance
}
this.processor.disconnect()
instance.attachedProcessors.splice(index, 1)
return instance
}
_findIndex(instance) {
if (!instance) {
instance = this.PlayerCore.currentAudioInstance
}
if (!instance) {
console.warn(`Instance is not defined`)
return false
}
// find index of the node within the attachedProcessors serching for matching refName
const index = instance.attachedProcessors.findIndex((node) => {
return node.constructor.refName === this.constructor.refName
})
if (index === -1) {
return false
}
return index
}
} }

View File

@ -84,9 +84,9 @@ export default class RemoteStorage extends Core {
_reject(message) _reject(message)
}) })
uploader.events.on("progress", ({ percentProgress }) => { uploader.events.on("progress", (data) => {
if (typeof onProgress === "function") { if (typeof onProgress === "function") {
onProgress(file, percentProgress) onProgress(file, data)
} }
}) })

View File

@ -6,102 +6,119 @@ import store from "store"
import config from "@config" import config from "@config"
export default class SFXCore extends Core { export default class SFXCore extends Core {
static namespace = "sfx" static namespace = "sfx"
soundsPool = {} soundsPool = {}
public = { public = {
loadSoundpack: this.loadSoundpack.bind(this), loadSoundpack: this.loadSoundpack.bind(this),
play: this.play, play: this.play,
} }
onEvents = { onEvents = {
"sfx:test": (volume) => { "sfx:test": (volume) => {
// play a sound to test volume // play a sound to test volume
this.play("test", { this.play("test", {
volume: volume / 100, volume: volume / 100,
}) })
} },
} }
async loadSoundpack(soundpack) { async loadSoundpack(soundpack) {
if (!soundpack) { if (!soundpack) {
soundpack = store.get("soundpack") soundpack = store.get("soundpack")
} }
if (!soundpack) { if (!soundpack) {
soundpack = config.defaultSoundPack ?? {} soundpack = config.defaultSoundPack ?? {}
} }
// check if is valid url with regex // check if is valid url with regex
const urlRegex = /^(http|https):\/\/[^ "]+$/; const urlRegex = /^(http|https):\/\/[^ "]+$/
if (urlRegex.test(soundpack)) { if (urlRegex.test(soundpack)) {
const { data } = await axios.get(soundpack) const { data } = await axios.get(soundpack)
soundpack = data soundpack = data
} }
if (typeof soundpack.sounds !== "object") { if (typeof soundpack.sounds !== "object") {
this.console.error(`Soundpack [${soundpack.id}] is not a valid soundpack.`) this.console.error(
return false `Soundpack [${soundpack.id}] is not a valid soundpack.`,
} )
return false
}
this.console.log(`Loading soundpack [${soundpack.id} | ${soundpack.name}] by ${soundpack.author} (${soundpack.version})`) this.console.log(
`Loading soundpack [${soundpack.id} | ${soundpack.name}] by ${soundpack.author} (${soundpack.version})`,
)
for (const [name, path] of Object.entries(soundpack.sounds)) { for (const [name, path] of Object.entries(soundpack.sounds)) {
this.soundsPool[name] = new Howl({ this.soundsPool[name] = new Howl({
volume: 0.5, volume: 0.5,
src: [path], src: [path],
}) })
} }
} }
async play(name, options = {}) { async play(name, options = {}) {
if (!window.app.cores.settings.is("ui.effects", true)) { if (!window.app.cores.settings.is("ui.effects", true)) {
return false return false
} }
const audioInstance = this.soundsPool[name] const audioInstance = this.soundsPool[name]
if (!audioInstance) { if (!audioInstance) {
return false return false
} }
if (typeof options.volume !== "undefined") { if (typeof options.volume !== "undefined") {
audioInstance.volume(options.volume) audioInstance.volume(options.volume)
} else { } else {
audioInstance.volume((window.app.cores.settings.get("ui.general_volume") ?? 0) / 100) audioInstance.volume(
} (window.app.cores.settings.get("ui.general_volume") ?? 0) / 100,
)
}
audioInstance.play() audioInstance.play()
} }
async handleClick(event) { async handleClick(event) {
// search for closest button // search for closest button
const button = event.target.closest("button") || event.target.closest(".ant-btn") const button =
event.target.closest("button") || event.target.closest(".ant-btn")
// search for a slider // search for a slider
const slider = event.target.closest("input[type=range]") const slider = event.target.closest("input[type=range]")
// if button exist and has aria-checked attribute then play switch_on or switch_off // if button exist and has aria-checked attribute then play switch_on or switch_off
if (button) { if (button) {
if (button.hasAttribute("aria-checked")) { if (button.hasAttribute("aria-checked")) {
return this.play(button.getAttribute("aria-checked") === "true" ? "component.switch_off" : "component.switch_on") return this.play(
} button.getAttribute("aria-checked") === "true"
? "component.switch_off"
: "component.switch_on",
)
}
return this.play("generic_click") return this.play("generic_click")
} }
if (slider) { if (slider) {
// check if is up or down // check if is up or down
this.console.log(slider) this.console.log(slider)
} }
} }
async onInitialize() { async onInitialize() {
await this.loadSoundpack() await this.loadSoundpack()
document.addEventListener("click", (...args) => { this.handleClick(...args) }, true) document.addEventListener(
} "click",
(...args) => {
this.handleClick(...args)
},
true,
)
}
} }

View File

@ -4,7 +4,7 @@ import classnames from "classnames"
import { Icons } from "@components/Icons" import { Icons } from "@components/Icons"
import SeekBar from "@components/Player/SeekBar" import SeekBar from "@components/Player/SeekBar"
import Controls from "@components/Player/Controls" import Controls from "@components/Player/Controls"
import ExtraActions from "@components/Player/ExtraActions" import Actions from "@components/Player/Actions"
import { usePlayerStateContext } from "@contexts/WithPlayerContext" import { usePlayerStateContext } from "@contexts/WithPlayerContext"
import RGBStringToValues from "@utils/rgbToValues" import RGBStringToValues from "@utils/rgbToValues"
@ -12,102 +12,96 @@ import RGBStringToValues from "@utils/rgbToValues"
import "./index.less" import "./index.less"
const ServiceIndicator = (props) => { const ServiceIndicator = (props) => {
if (!props.service) { if (!props.service) {
return null return null
} }
switch (props.service) { switch (props.service) {
case "tidal": { case "tidal": {
return <div className="service_indicator"> return (
<Icons.SiTidal /> Playing from Tidal <div className="service_indicator">
</div> <Icons.SiTidal /> Playing from Tidal
} </div>
default: { )
return null }
} default: {
} return null
}
}
} }
const AudioPlayer = (props) => { const AudioPlayer = (props) => {
const [playerState] = usePlayerStateContext() const [playerState] = usePlayerStateContext()
React.useEffect(() => { React.useEffect(() => {
if (app.currentDragger) { if (app.currentDragger) {
app.currentDragger.setBackgroundColorValues(RGBStringToValues(playerState.track_manifest?.cover_analysis?.rgb)) app.currentDragger.setBackgroundColorValues(
} RGBStringToValues(
playerState.track_manifest?.cover_analysis?.rgb,
),
)
}
}, [playerState.track_manifest?.cover_analysis])
}, [playerState.track_manifest?.cover_analysis]) const {
title,
album,
artist,
service,
lyricsEnabled,
cover_analysis,
cover,
} = playerState.track_manifest ?? {}
const { const playing = playerState.playback_status === "playing"
title, const stopped = playerState.playback_status === "stopped"
album,
artist,
service,
lyricsEnabled,
cover_analysis,
cover,
} = playerState.track_manifest ?? {}
const playing = playerState.playback_status === "playing" const titleText = !playing && stopped ? "Stopped" : (title ?? "Untitled")
const stopped = playerState.playback_status === "stopped" const subtitleText = `${artist} | ${album?.title ?? album}`
const titleText = (!playing && stopped) ? "Stopped" : (title ?? "Untitled") return (
const subtitleText = `${artist} | ${album?.title ?? album}` <div
className={classnames("mobile-player_wrapper", {
cover_light: cover_analysis?.isLight,
})}
style={{
"--cover_isLight": cover_analysis?.isLight,
}}
>
<div className="mobile-player">
<ServiceIndicator service={service} />
return <div <div
className={classnames( className="mobile-player-cover"
"mobile_media_player_wrapper", style={{
{ backgroundImage: `url(${cover ?? "/assets/no_song.png"})`,
"cover_light": cover_analysis?.isLight, }}
} />
)}
style={{
"--cover_isLight": cover_analysis?.isLight,
}}
>
<div className="mobile_media_player">
<ServiceIndicator
service={service}
/>
<div <div className="mobile-player-header">
className="cover" <div className="mobile-player-info">
style={{ <div className="mobile-player-info-title">
backgroundImage: `url(${cover ?? "/assets/no_song.png"})`, <h1>{titleText}</h1>
}} </div>
/> <div className="mobile-player-info-subTitle">
<span>{subtitleText}</span>
</div>
</div>
</div>
<div className="header"> <Controls />
<div className="info">
<div className="title">
<h2>
{
titleText
}
</h2>
</div>
<div className="subTitle">
<div className="artist">
<h3>
{subtitleText}
</h3>
</div>
</div>
</div>
</div>
<Controls /> <SeekBar
stopped={playerState.playback_status === "stopped"}
playing={playerState.playback_status === "playing"}
streamMode={playerState.livestream_mode}
disabled={playerState.control_locked}
/>
<SeekBar <Actions />
stopped={playerState.playback_status === "stopped"} </div>
playing={playerState.playback_status === "playing"} </div>
streamMode={playerState.livestream_mode} )
disabled={playerState.control_locked}
/>
<ExtraActions />
</div>
</div>
} }
export default AudioPlayer export default AudioPlayer

View File

@ -1,199 +1,184 @@
@top_controls_height: 55px; @top_controls_height: 55px;
.mobile_media_player_wrapper { .mobile-player_wrapper {
position: relative; position: relative;
z-index: 320; z-index: 320;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
width: 100%; width: 100%;
.mobile_media_player_background { margin-bottom: 30px;
position: absolute;
z-index: 320; .mobile-player_background {
position: absolute;
top: 0; z-index: 320;
left: 0;
width: 100%; top: 0;
height: 100%; left: 0;
background-color: rgba(var(--cover_averageValues), 0.4); width: 100%;
} height: 100%;
background-color: rgba(var(--cover_averageValues), 0.4);
}
} }
.mobile_media_player { .mobile-player {
position: relative; position: relative;
display: inline-flex; display: inline-flex;
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
justify-content: center; justify-content: center;
width: 100%; width: 100%;
height: 100%; height: 100%;
gap: 10px; gap: 10px;
transition: all 150ms ease-out; transition: all 150ms ease-out;
z-index: 330; z-index: 330;
.service_indicator { .service_indicator {
color: var(--text-color); color: var(--text-color);
background-color: var(--background-color-accent); background-color: var(--background-color-accent);
padding: 7px; padding: 7px;
border-radius: 8px; border-radius: 8px;
font-size: 0.9rem; font-size: 0.9rem;
} }
.cover { .mobile-player-cover {
position: relative; position: relative;
z-index: 320; z-index: 320;
margin: auto; margin: auto;
width: 100%; width: 100%;
height: 100%; height: 100%;
min-height: 40vh; min-height: 40vh;
min-width: 100%; min-width: 100%;
border-radius: 24px; border-radius: 24px;
background-position: center; background-position: center;
background-size: cover; background-size: cover;
background-repeat: no-repeat; background-repeat: no-repeat;
transition: all 0.3s ease-in-out; transition: all 0.3s ease-in-out;
img { img {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
object-position: center; object-position: center;
} }
} }
.header { .mobile-player-header {
position: relative; position: relative;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
width: 100%; width: 100%;
.info { .mobile-player-info {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
h1, h1,
h2, h2,
h3, h3,
h4, h4,
h5, h5,
h6, h6,
p, p,
span { span {
margin: 0; margin: 0;
color: var(--text-color); color: var(--text-color);
} }
width: 100%; width: 100%;
.title { .mobile-player-info-title {
font-size: 1rem; display: flex;
font-weight: 600; flex-direction: row;
color: var(--text-color);
word-break: break-all; align-items: center;
font-family: "Space Grotesk", sans-serif; font-size: 1rem;
} font-weight: 600;
.subTitle { word-break: break-all;
display: flex;
flex-direction: row;
width: 100%; font-family: "Space Grotesk", sans-serif;
}
justify-content: space-between; .mobile-player-info-subTitle {
display: flex;
flex-direction: row;
.likeButton { align-items: center;
margin-right: 20px; justify-content: space-between;
}
.artist { font-size: 0.7rem;
font-size: 0.6rem; font-weight: 400;
font-weight: 400; }
color: var(--text-color); }
} }
}
}
}
.player-controls { .player-controls {
.ant-btn { .ant-btn {
min-width: 40px !important; min-width: 40px !important;
min-height: 40px !important; min-height: 40px !important;
} }
svg { svg {
font-size: 1.2rem; font-size: 1.2rem;
} }
.playButton { .playButton {
min-width: 50px !important; min-width: 50px !important;
min-height: 50px !important; min-height: 50px !important;
svg { svg {
font-size: 1.6rem; font-size: 1.6rem;
} }
} }
} }
.player-seek_bar { .player-seek_bar {
.progress { .progress {
.MuiSlider-root { .MuiSlider-root {
.MuiSlider-rail { .MuiSlider-rail {
height: 7px; height: 7px;
} }
.MuiSlider-track { .MuiSlider-track {
height: 7px; height: 7px;
} }
.MuiSlider-thumb { .MuiSlider-thumb {
width: 5px; width: 5px;
height: 13px; height: 13px;
border-radius: 2px; border-radius: 2px;
background-color: var(--background-color-contrast); background-color: var(--background-color-contrast);
} }
} }
} }
} }
.extra_actions {
padding: 0 30px;
.ant-btn {
padding: 5px;
svg {
height: 23px;
min-width: 23px;
}
}
}
} }

View File

@ -0,0 +1,47 @@
import TrackManifest from "@cores/player/classes/TrackManifest"
const D_Manifest = () => {
const [manifest, setManifest] = React.useState(null)
function selectLocalFile() {
const input = document.createElement("input")
input.type = "file"
input.accept = "audio/*"
input.onchange = (e) => {
loadManifest(e.target.files[0])
}
input.click()
}
async function loadManifest(file) {
let track = new TrackManifest({ file: file })
await track.initialize()
console.log(track)
setManifest(track)
}
return (
<div className="flex-column gap-10">
<p>Select a local file to view & create a track manifest</p>
<button onClick={selectLocalFile}>Select</button>
{manifest?.cover && (
<img
src={manifest.cover}
alt="Cover"
style={{ width: "100px", height: "100px" }}
/>
)}
<code style={{ whiteSpace: "break-spaces", width: "300px" }}>
{JSON.stringify(manifest)}
</code>
</div>
)
}
export default D_Manifest

View File

@ -177,7 +177,7 @@ const PlayerController = React.forwardRef((props, ref) => {
)} )}
<div className="lyrics-player-controller-tags"> <div className="lyrics-player-controller-tags">
{playerState.track_manifest?.metadata.lossless && ( {playerState.track_manifest?.metadata?.lossless && (
<Tag <Tag
icon={ icon={
<Icons.Lossless <Icons.Lossless

View File

@ -7,178 +7,197 @@ import { usePlayerStateContext } from "@contexts/WithPlayerContext"
const maxLatencyInMs = 55 const maxLatencyInMs = 55
const LyricsVideo = React.forwardRef((props, videoRef) => { const LyricsVideo = React.forwardRef((props, videoRef) => {
const [playerState] = usePlayerStateContext() const [playerState] = usePlayerStateContext()
const { lyrics } = props const { lyrics } = props
const [initialLoading, setInitialLoading] = React.useState(true) const [initialLoading, setInitialLoading] = React.useState(true)
const [syncInterval, setSyncInterval] = React.useState(null) const [syncInterval, setSyncInterval] = React.useState(null)
const [syncingVideo, setSyncingVideo] = React.useState(false) const [syncingVideo, setSyncingVideo] = React.useState(false)
const [currentVideoLatency, setCurrentVideoLatency] = React.useState(0) const [currentVideoLatency, setCurrentVideoLatency] = React.useState(0)
const hls = React.useRef(new HLS()) const hls = React.useRef(new HLS())
async function seekVideoToSyncAudio() { async function seekVideoToSyncAudio() {
if (!lyrics) { if (!lyrics) {
return null return null
} }
if (!lyrics.video_source || typeof lyrics.sync_audio_at_ms === "undefined") { if (
return null !lyrics.video_source ||
} typeof lyrics.sync_audio_at_ms === "undefined"
) {
return null
}
const currentTrackTime = app.cores.player.controls.seek() const currentTrackTime = app.cores.player.controls.seek()
setSyncingVideo(true) setSyncingVideo(true)
let newTime = currentTrackTime + (lyrics.sync_audio_at_ms / 1000) + app.cores.player.gradualFadeMs / 1000 let newTime =
currentTrackTime + lyrics.sync_audio_at_ms / 1000 + 150 / 1000
// dec some ms to ensure the video seeks correctly // dec some ms to ensure the video seeks correctly
newTime -= 5 / 1000 newTime -= 5 / 1000
videoRef.current.currentTime = newTime videoRef.current.currentTime = newTime
} }
async function syncPlayback() { async function syncPlayback() {
// if something is wrong, stop syncing // if something is wrong, stop syncing
if (videoRef.current === null || !lyrics || !lyrics.video_source || typeof lyrics.sync_audio_at_ms === "undefined" || playerState.playback_status !== "playing") { if (
return stopSyncInterval() videoRef.current === null ||
} !lyrics ||
!lyrics.video_source ||
typeof lyrics.sync_audio_at_ms === "undefined" ||
playerState.playback_status !== "playing"
) {
return stopSyncInterval()
}
const currentTrackTime = app.cores.player.controls.seek() const currentTrackTime = app.cores.player.controls.seek()
const currentVideoTime = videoRef.current.currentTime - (lyrics.sync_audio_at_ms / 1000) const currentVideoTime =
videoRef.current.currentTime - lyrics.sync_audio_at_ms / 1000
//console.log(`Current track time: ${currentTrackTime}, current video time: ${currentVideoTime}`) //console.log(`Current track time: ${currentTrackTime}, current video time: ${currentVideoTime}`)
const maxOffset = maxLatencyInMs / 1000 const maxOffset = maxLatencyInMs / 1000
const currentVideoTimeDiff = Math.abs(currentVideoTime - currentTrackTime) const currentVideoTimeDiff = Math.abs(
currentVideoTime - currentTrackTime,
)
setCurrentVideoLatency(currentVideoTimeDiff) setCurrentVideoLatency(currentVideoTimeDiff)
if (syncingVideo === true) { if (syncingVideo === true) {
return false return false
} }
if (currentVideoTimeDiff > maxOffset) { if (currentVideoTimeDiff > maxOffset) {
seekVideoToSyncAudio() seekVideoToSyncAudio()
} }
} }
function startSyncInterval() { function startSyncInterval() {
setSyncInterval(setInterval(syncPlayback, 300)) setSyncInterval(setInterval(syncPlayback, 300))
} }
function stopSyncInterval() { function stopSyncInterval() {
setSyncingVideo(false) setSyncingVideo(false)
setSyncInterval(null) setSyncInterval(null)
clearInterval(syncInterval) clearInterval(syncInterval)
} }
//* handle when player is loading //* handle when player is loading
React.useEffect(() => { React.useEffect(() => {
if (lyrics?.video_source && playerState.loading === true && playerState.playback_status === "playing") { if (
videoRef.current.pause() lyrics?.video_source &&
} playerState.loading === true &&
playerState.playback_status === "playing"
) {
videoRef.current.pause()
}
if (lyrics?.video_source && playerState.loading === false && playerState.playback_status === "playing") { if (
videoRef.current.play() lyrics?.video_source &&
} playerState.loading === false &&
}, [playerState.loading]) playerState.playback_status === "playing"
) {
videoRef.current.play()
}
}, [playerState.loading])
//* Handle when playback status change //* Handle when playback status change
React.useEffect(() => { React.useEffect(() => {
if (initialLoading === false) { if (initialLoading === false) {
console.log(`VIDEO:: Playback status changed to ${playerState.playback_status}`) console.log(
`VIDEO:: Playback status changed to ${playerState.playback_status}`,
)
if (lyrics && lyrics.video_source) { if (lyrics && lyrics.video_source) {
if (playerState.playback_status === "playing") { if (playerState.playback_status === "playing") {
videoRef.current.play() videoRef.current.play()
startSyncInterval() startSyncInterval()
} else { } else {
videoRef.current.pause() videoRef.current.pause()
stopSyncInterval() stopSyncInterval()
} }
} }
} }
}, [playerState.playback_status]) }, [playerState.playback_status])
//* Handle when lyrics object change //* Handle when lyrics object change
React.useEffect(() => { React.useEffect(() => {
setCurrentVideoLatency(0) setCurrentVideoLatency(0)
stopSyncInterval() stopSyncInterval()
if (lyrics) { if (lyrics) {
if (lyrics.video_source) { if (lyrics.video_source) {
console.log("Loading video source >", lyrics.video_source) console.log("Loading video source >", lyrics.video_source)
if (lyrics.video_source.endsWith(".mp4")) { if (lyrics.video_source.endsWith(".mp4")) {
videoRef.current.src = lyrics.video_source videoRef.current.src = lyrics.video_source
} else { } else {
hls.current.loadSource(lyrics.video_source) hls.current.loadSource(lyrics.video_source)
} }
if (typeof lyrics.sync_audio_at_ms !== "undefined") { if (typeof lyrics.sync_audio_at_ms !== "undefined") {
videoRef.current.loop = false videoRef.current.loop = false
videoRef.current.currentTime = lyrics.sync_audio_at_ms / 1000 videoRef.current.currentTime =
lyrics.sync_audio_at_ms / 1000
startSyncInterval() startSyncInterval()
} else { } else {
videoRef.current.loop = true videoRef.current.loop = true
videoRef.current.currentTime = 0 videoRef.current.currentTime = 0
} }
if (playerState.playback_status === "playing") { if (playerState.playback_status === "playing") {
videoRef.current.play() videoRef.current.play()
} }
} }
} }
setInitialLoading(false) setInitialLoading(false)
}, [lyrics]) }, [lyrics])
React.useEffect(() => { React.useEffect(() => {
videoRef.current.addEventListener("seeked", (event) => { videoRef.current.addEventListener("seeked", (event) => {
setSyncingVideo(false) setSyncingVideo(false)
}) })
hls.current.attachMedia(videoRef.current) hls.current.attachMedia(videoRef.current)
return () => { return () => {
stopSyncInterval() stopSyncInterval()
} }
}, []) }, [])
return <> return (
{ <>
props.lyrics?.sync_audio_at && <div {props.lyrics?.sync_audio_at && (
className={classnames( <div className={classnames("videoDebugOverlay")}>
"videoDebugOverlay", <div>
)} <p>Maximun latency</p>
> <p>{maxLatencyInMs}ms</p>
<div> </div>
<p>Maximun latency</p> <div>
<p>{maxLatencyInMs}ms</p> <p>Video Latency</p>
</div> <p>{(currentVideoLatency * 1000).toFixed(2)}ms</p>
<div> </div>
<p>Video Latency</p> {syncingVideo ? <p>Syncing video...</p> : null}
<p>{(currentVideoLatency * 1000).toFixed(2)}ms</p> </div>
</div> )}
{syncingVideo ? <p>Syncing video...</p> : null}
</div>
}
<video <video
className={classnames( className={classnames("lyrics-video", {
"lyrics-video", ["hidden"]: !lyrics || !lyrics?.video_source,
{ })}
["hidden"]: !lyrics || !lyrics?.video_source ref={videoRef}
} controls={false}
)} muted
ref={videoRef} preload="auto"
controls={false} />
muted </>
preload="auto" )
/>
</>
}) })
export default LyricsVideo export default LyricsVideo

View File

@ -89,7 +89,8 @@ const EnhancedLyricsPage = () => {
// Track manifest comparison // Track manifest comparison
useEffect(() => { useEffect(() => {
const newManifest = playerState.track_manifest?.toSeriableObject() const newManifest = playerState.track_manifest
if (JSON.stringify(newManifest) !== JSON.stringify(trackManifest)) { if (JSON.stringify(newManifest) !== JSON.stringify(trackManifest)) {
setTrackManifest(newManifest) setTrackManifest(newManifest)
} }

View File

@ -11,33 +11,33 @@ export default {
} }
}, },
settings: [ settings: [
{ // {
id: "player.gain", // id: "player.gain",
title: "Gain", // title: "Gain",
icon: "MdGraphicEq", // icon: "MdGraphicEq",
group: "general", // group: "general",
description: "Adjust gain for audio output", // description: "Adjust gain for audio output",
component: "Slider", // component: "Slider",
props: { // props: {
min: 1, // min: 1,
max: 2, // max: 2,
step: 0.1, // step: 0.1,
marks: { // marks: {
1: "Normal", // 1: "Normal",
1.5: "+50%", // 1.5: "+50%",
2: "+100%", // 2: "+100%",
}, // },
}, // },
defaultValue: () => { // defaultValue: () => {
return app.cores.player.gain.values().gain // return app.cores.player.gain.values().gain
}, // },
onUpdate: (value) => { // onUpdate: (value) => {
app.cores.player.gain.modifyValues({ // app.cores.player.gain.modifyValues({
gain: value, // gain: value,
}) // })
}, // },
storaged: false, // storaged: false,
}, // },
{ {
id: "player.sample_rate", id: "player.sample_rate",
title: "Sample Rate", title: "Sample Rate",
@ -66,7 +66,7 @@ export default {
], ],
}, },
defaultValue: (ctx) => { defaultValue: (ctx) => {
return app.cores.player.audioContext.sampleRate return app.cores.player.base().context.sampleRate
}, },
onUpdate: async (value) => { onUpdate: async (value) => {
const sampleRate = const sampleRate =
@ -94,10 +94,10 @@ export default {
onEnabledChange: (enabled) => { onEnabledChange: (enabled) => {
if (enabled === true) { if (enabled === true) {
app.cores.settings.set("player.compressor", true) app.cores.settings.set("player.compressor", true)
app.cores.player.compressor.attach() //app.cores.player.compressor.attach()
} else { } else {
app.cores.settings.set("player.compressor", false) app.cores.settings.set("player.compressor", false)
app.cores.player.compressor.detach() //app.cores.player.compressor.detach()
} }
}, },
extraActions: [ extraActions: [
@ -106,8 +106,9 @@ export default {
title: "Default", title: "Default",
icon: "MdRefresh", icon: "MdRefresh",
onClick: async (ctx) => { onClick: async (ctx) => {
const values = const values = await app.cores.player
await app.cores.player.compressor.presets.setCurrentPresetToDefault() .base()
.processors.compressor.presets.setCurrentPresetToDefault()
ctx.updateCurrentValue(values) ctx.updateCurrentValue(values)
}, },
@ -152,13 +153,14 @@ export default {
], ],
}, },
onUpdate: (value) => { onUpdate: (value) => {
app.cores.player.compressor.presets.setToCurrent(value) app.cores.player
.base()
.processors.compressor.presets.setToCurrent(value)
return value return value
}, },
storaged: false, storaged: false,
}, },
{ {
id: "player.eq", id: "player.eq",
title: "Equalizer", title: "Equalizer",
@ -172,8 +174,9 @@ export default {
title: "Reset", title: "Reset",
icon: "MdRefresh", icon: "MdRefresh",
onClick: (ctx) => { onClick: (ctx) => {
const values = const values = app.cores.player
app.cores.player.eq.presets.setCurrentPresetToDefault() .base()
.processors.eq.presets.setCurrentPresetToDefault()
ctx.updateCurrentValue(values) ctx.updateCurrentValue(values)
}, },
@ -260,7 +263,9 @@ export default {
return acc return acc
}, {}) }, {})
app.cores.player.eq.presets.setToCurrent(values) app.cores.player
.base()
.processors.eq.presets.setToCurrent(values)
return value return value
}, },

View File

@ -2,13 +2,11 @@ import { Switch } from "antd"
import SlidersWithPresets from "../../../components/slidersWithPresets" import SlidersWithPresets from "../../../components/slidersWithPresets"
export default (props) => { export default (props) => {
return <SlidersWithPresets return (
{...props} <SlidersWithPresets
controller={app.cores.player.compressor.presets} {...props}
extraHeaderItems={[ controller={app.cores.player.base().processors.compressor.presets}
<Switch extraHeaderItems={[<Switch onChange={props.onEnabledChange} />]}
onChange={props.onEnabledChange} />
/> )
]}
/>
} }

View File

@ -1,8 +1,10 @@
import SlidersWithPresets from "../../../components/slidersWithPresets" import SlidersWithPresets from "../../../components/slidersWithPresets"
export default (props) => { export default (props) => {
return <SlidersWithPresets return (
{...props} <SlidersWithPresets
controller={app.cores.player.eq.presets} {...props}
/> controller={app.cores.player.base().processors.eq.presets}
/>
)
} }

View File

@ -0,0 +1,99 @@
export default function compareObjectsByProperties(obj1, obj2, props) {
// validate that obj1 and obj2 are objects
if (
!obj1 ||
!obj2 ||
typeof obj1 !== "object" ||
typeof obj2 !== "object"
) {
return false
}
// validate that props is an array
if (!Array.isArray(props)) {
throw new Error("The props parameter must be an array")
}
// iterate through each property and compare
for (const prop of props) {
// check if the property exists in both objects
const prop1Exists = prop in obj1
const prop2Exists = prop in obj2
// if the property doesnt exist in one of the objects
if (prop1Exists !== prop2Exists) {
return false
}
// if the property exists in both, compare values
if (prop1Exists && prop2Exists) {
// for nested objects, perform deep comparison
if (
typeof obj1[prop] === "object" &&
obj1[prop] !== null &&
typeof obj2[prop] === "object" &&
obj2[prop] !== null
) {
// compare arrays
if (Array.isArray(obj1[prop]) && Array.isArray(obj2[prop])) {
if (obj1[prop].length !== obj2[prop].length) {
return false
}
for (let i = 0; i < obj1[prop].length; i++) {
// if elements are objects, call recursively
if (
typeof obj1[prop][i] === "object" &&
typeof obj2[prop][i] === "object"
) {
// get all properties of the object
const nestedProps = [
...new Set([
...Object.keys(obj1[prop][i]),
...Object.keys(obj2[prop][i]),
]),
]
if (
!compareObjectsByProperties(
obj1[prop][i],
obj2[prop][i],
nestedProps,
)
) {
return false
}
} else if (obj1[prop][i] !== obj2[prop][i]) {
return false
}
}
}
// compare objects
else {
const nestedProps = [
...new Set([
...Object.keys(obj1[prop]),
...Object.keys(obj2[prop]),
]),
]
if (
!compareObjectsByProperties(
obj1[prop],
obj2[prop],
nestedProps,
)
) {
return false
}
}
}
// for primitive values, compare directly
else if (obj1[prop] !== obj2[prop]) {
return false
}
}
}
return true
}

View File

@ -1,15 +0,0 @@
{
"$schema": "http://json.schemastore.org/swcrc",
"exclude":[
"node_modules/minio/**",
"node_modules/@octokit/**"
],
"module": {
"type": "commonjs",
// These are defaults.
"strict": false,
"strictMode": true,
"lazy": false,
"noInterop": false
}
}

View File

@ -7,6 +7,7 @@ RUN apt install -y --no-install-recommends build-essential
RUN apt install -y --no-install-recommends git RUN apt install -y --no-install-recommends git
RUN apt install -y --no-install-recommends ssh RUN apt install -y --no-install-recommends ssh
RUN apt install -y --no-install-recommends curl RUN apt install -y --no-install-recommends curl
RUN apt install -y --no-install-recommends nscd
RUN apt install -y --no-install-recommends ca-certificates RUN apt install -y --no-install-recommends ca-certificates
RUN apt install -y --no-install-recommends ffmpeg RUN apt install -y --no-install-recommends ffmpeg

View File

@ -8,17 +8,6 @@ const { Buffer } = require("node:buffer")
const { webcrypto: crypto } = require("node:crypto") const { webcrypto: crypto } = require("node:crypto")
const { InfisicalClient } = require("@infisical/sdk") const { InfisicalClient } = require("@infisical/sdk")
const moduleAlias = require("module-alias") const moduleAlias = require("module-alias")
const { onExit } = require("signal-exit")
const opentelemetry = require("@opentelemetry/sdk-node")
const {
getNodeAutoInstrumentations,
} = require("@opentelemetry/auto-instrumentations-node")
const { OTLPTraceExporter } = require("@opentelemetry/exporter-trace-otlp-http")
const { OTLPLogExporter } = require("@opentelemetry/exporter-logs-otlp-http")
const { Resource } = require("@opentelemetry/resources")
const {
SemanticResourceAttributes,
} = require("@opentelemetry/semantic-conventions")
// Override file execution arg // Override file execution arg
process.argv.splice(1, 1) process.argv.splice(1, 1)
@ -164,34 +153,12 @@ async function Boot(main) {
throw new Error("main class is not defined") throw new Error("main class is not defined")
} }
const service_id = process.env.lb_service.id const { lb_service_id } = process.env
console.log( console.log(
`[BOOT] Booting (${service_id}) in [${global.isProduction ? "production" : "development"}] mode...`, `[BOOT] Booting in [${global.isProduction ? "production" : "development"}] mode...`,
) )
const traceExporter = new OTLPTraceExporter({
url:
process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT ??
"http://fr02.ragestudio.net:4318/v1/traces",
})
const logExporter = new OTLPLogExporter({
url:
process.env.OTEL_EXPORTER_OTLP_LOGS_ENDPOINT ??
"http://fr02.ragestudio.net:4318/v1/logs",
})
const sdk = new opentelemetry.NodeSDK({
traceExporter,
logExporter,
instrumentations: [getNodeAutoInstrumentations()],
resource: new Resource({
[SemanticResourceAttributes.SERVICE_NAME]: service_id ?? "node_app",
}),
})
sdk.start()
if ( if (
process.env.INFISICAL_CLIENT_ID && process.env.INFISICAL_CLIENT_ID &&
process.env.INFISICAL_CLIENT_SECRET process.env.INFISICAL_CLIENT_SECRET
@ -204,30 +171,35 @@ async function Boot(main) {
const instance = new main() const instance = new main()
onExit( process.on("exit", (code) => {
(code, signal) => { console.log(`[BOOT] Closing...`)
console.log(`[BOOT] Cleaning up...`)
sdk.shutdown()
.then(() => console.log("Tracing terminated"))
.catch((error) =>
console.log("Error terminating tracing", error),
)
if (instance._fireClose) {
instance._fireClose()
} else {
if (typeof instance.onClose === "function") { if (typeof instance.onClose === "function") {
instance.onClose() instance.onClose()
} }
instance.engine.close() instance.engine.close()
}, }
{ })
alwaysLast: true,
}, process.on("SIGTERM", () => {
) process.exit(0)
})
process.on("SIGINT", () => {
process.exit(0)
})
process.on("SIGTERM", () => {
process.exit(0)
})
await instance.initialize() await instance.initialize()
if (process.env.lb_service && process.send) { if (lb_service_id && process.send) {
process.send({ process.send({
status: "ready", status: "ready",
}) })

View File

@ -107,8 +107,7 @@ export async function handleChunkFile(
{ tmpDir, headers, maxFileSize, maxChunkSize }, { tmpDir, headers, maxFileSize, maxChunkSize },
) { ) {
return await new Promise(async (resolve, reject) => { return await new Promise(async (resolve, reject) => {
const workPath = path.join(tmpDir, headers["uploader-file-id"]) const chunksPath = path.join(tmpDir, "chunks")
const chunksPath = path.join(workPath, "chunks")
const chunkPath = path.join( const chunkPath = path.join(
chunksPath, chunksPath,
headers["uploader-chunk-number"], headers["uploader-chunk-number"],
@ -188,7 +187,7 @@ export async function handleChunkFile(
// build data // build data
chunksPath: chunksPath, chunksPath: chunksPath,
filePath: path.resolve( filePath: path.resolve(
workPath, tmpDir,
`${filename}.${extension}`, `${filename}.${extension}`,
), ),
maxFileSize: maxFileSize, maxFileSize: maxFileSize,
@ -207,38 +206,4 @@ export async function handleChunkFile(
}) })
} }
export async function uploadChunkFile(
req,
{ tmpDir, maxFileSize, maxChunkSize },
) {
// create a readable stream from req.body data blob
//
const chunkData = new Blob([req.body], { type: "application/octet-stream" })
console.log(chunkData)
if (!checkChunkUploadHeaders(req.headers)) {
reject(new OperationError(400, "Missing header(s)"))
return
}
// return await new Promise(async (resolve, reject) => {
// // create a readable node stream from "req.body" (octet-stream)
// await req.multipart(async (field) => {
// try {
// const result = await handleChunkFile(field.file.stream, {
// tmpDir: tmpDir,
// headers: req.headers,
// maxFileSize: maxFileSize,
// maxChunkSize: maxChunkSize,
// })
// return resolve(result)
// } catch (error) {
// return reject(error)
// }
// })
// })
}
export default uploadChunkFile export default uploadChunkFile

View File

@ -0,0 +1,110 @@
import { EventEmitter } from "node:events"
import child_process from "node:child_process"
function getBinaryPath(name) {
try {
return child_process
.execSync(`which ${name}`, { encoding: "utf8" })
.trim()
} catch (error) {
return null
}
}
export class FFMPEGLib extends EventEmitter {
constructor() {
super()
this.ffmpegBin = getBinaryPath("ffmpeg")
this.ffprobeBin = getBinaryPath("ffprobe")
}
handleProgress(stdout, endTime, onProgress = () => {}) {
let currentTime = 0
stdout.on("data", (data) => {
for (const line of data.toString().split("\n")) {
if (line.startsWith("out_time_ms=")) {
currentTime = parseInt(line.split("=")[1]) / 1000000
} else if (line.startsWith("progress=")) {
const status = line.split("=")[1]
if (status === "end") {
onProgress(100)
} else if (endTime > 0 && currentTime > 0) {
onProgress(
Math.min(
100,
Math.round((currentTime / endTime) * 100),
),
)
}
}
}
})
}
ffmpeg(payload) {
return this.exec(this.ffmpegBin, payload)
}
ffprobe(payload) {
return this.exec(this.ffprobeBin, payload)
}
exec(bin, { args, onProcess, cwd }) {
if (Array.isArray(args)) {
args = args.join(" ")
}
return new Promise((resolve, reject) => {
const process = child_process.exec(
`${bin} ${args}`,
{
cwd: cwd,
},
(error, stdout, stderr) => {
if (error) {
reject(stderr)
} else {
resolve(stdout.toString())
}
},
)
if (typeof onProcess === "function") {
onProcess(process)
}
})
}
}
export class Utils {
static async probe(input) {
const lib = new FFMPEGLib()
const result = await lib
.ffprobe({
args: [
"-v",
"error",
"-print_format",
"json",
"-show_format",
"-show_streams",
input,
],
})
.catch((err) => {
console.log(err)
return null
})
if (!result) {
return null
}
return JSON.parse(result)
}
}
export default FFMPEGLib

View File

@ -1,147 +1,138 @@
import fs from "node:fs" import fs from "node:fs"
import path from "node:path" import path from "node:path"
import { exec } from "node:child_process"
import { EventEmitter } from "node:events"
export default class MultiqualityHLSJob { import { FFMPEGLib, Utils } from "../FFMPEGLib"
constructor({
input,
outputDir,
outputMasterName = "master.m3u8",
levels,
}) {
this.input = input
this.outputDir = outputDir
this.levels = levels
this.outputMasterName = outputMasterName
this.bin = require("ffmpeg-static") export default class MultiqualityHLSJob extends FFMPEGLib {
constructor(params = {}) {
super()
return this this.params = {
} outputMasterName: "master.m3u8",
levels: [
{
original: true,
codec: "libx264",
bitrate: "10M",
preset: "ultrafast",
},
],
...params,
}
}
events = new EventEmitter() buildArgs = () => {
const cmdStr = [
`-v error -hide_banner -progress pipe:1`,
`-i ${this.params.input}`,
`-filter_complex`,
]
buildCommand = () => { // set split args
const cmdStr = [ let splitLevels = [`[0:v]split=${this.params.levels.length}`]
this.bin,
`-v quiet -stats`,
`-i ${this.input}`,
`-filter_complex`,
]
// set split args this.params.levels.forEach((level, i) => {
let splitLevels = [ splitLevels[0] += `[v${i + 1}]`
`[0:v]split=${this.levels.length}` })
]
this.levels.forEach((level, i) => { for (const [index, level] of this.params.levels.entries()) {
splitLevels[0] += (`[v${i + 1}]`) if (level.original) {
}) splitLevels.push(`[v1]copy[v1out]`)
continue
}
for (const [index, level] of this.levels.entries()) { let scaleFilter = `[v${index + 1}]scale=w=${level.width}:h=trunc(ow/a/2)*2[v${index + 1}out]`
if (level.original) {
splitLevels.push(`[v1]copy[v1out]`)
continue
}
let scaleFilter = `[v${index + 1}]scale=w=${level.width}:h=trunc(ow/a/2)*2[v${index + 1}out]` splitLevels.push(scaleFilter)
}
splitLevels.push(scaleFilter) cmdStr.push(`"${splitLevels.join(";")}"`)
}
cmdStr.push(`"${splitLevels.join(";")}"`) // set levels map
for (const [index, level] of this.params.levels.entries()) {
let mapArgs = [
`-map "[v${index + 1}out]"`,
`-x264-params "nal-hrd=cbr:force-cfr=1"`,
`-c:v:${index} ${level.codec}`,
`-b:v:${index} ${level.bitrate}`,
`-maxrate:v:${index} ${level.bitrate}`,
`-minrate:v:${index} ${level.bitrate}`,
`-bufsize:v:${index} ${level.bitrate}`,
`-preset ${level.preset}`,
`-g 48`,
`-sc_threshold 0`,
`-keyint_min 48`,
]
// set levels map cmdStr.push(...mapArgs)
for (const [index, level] of this.levels.entries()) { }
let mapArgs = [
`-map "[v${index + 1}out]"`,
`-x264-params "nal-hrd=cbr:force-cfr=1"`,
`-c:v:${index} ${level.codec}`,
`-b:v:${index} ${level.bitrate}`,
`-maxrate:v:${index} ${level.bitrate}`,
`-minrate:v:${index} ${level.bitrate}`,
`-bufsize:v:${index} ${level.bitrate}`,
`-preset ${level.preset}`,
`-g 48`,
`-sc_threshold 0`,
`-keyint_min 48`,
]
cmdStr.push(...mapArgs) // set output
} cmdStr.push(`-f hls`)
cmdStr.push(`-hls_time 2`)
cmdStr.push(`-hls_playlist_type vod`)
cmdStr.push(`-hls_flags independent_segments`)
cmdStr.push(`-hls_segment_type mpegts`)
cmdStr.push(`-hls_segment_filename stream_%v/data%02d.ts`)
cmdStr.push(`-master_pl_name ${this.params.outputMasterName}`)
// set output cmdStr.push(`-var_stream_map`)
cmdStr.push(`-f hls`)
cmdStr.push(`-hls_time 2`)
cmdStr.push(`-hls_playlist_type vod`)
cmdStr.push(`-hls_flags independent_segments`)
cmdStr.push(`-hls_segment_type mpegts`)
cmdStr.push(`-hls_segment_filename stream_%v/data%02d.ts`)
cmdStr.push(`-master_pl_name ${this.outputMasterName}`)
cmdStr.push(`-var_stream_map`) let streamMapVar = []
let streamMapVar = [] for (const [index, level] of this.params.levels.entries()) {
streamMapVar.push(`v:${index}`)
}
for (const [index, level] of this.levels.entries()) { cmdStr.push(`"${streamMapVar.join(" ")}"`)
streamMapVar.push(`v:${index}`) cmdStr.push(`"stream_%v/stream.m3u8"`)
}
cmdStr.push(`"${streamMapVar.join(" ")}"`) return cmdStr.join(" ")
cmdStr.push(`"stream_%v/stream.m3u8"`) }
return cmdStr.join(" ") run = async () => {
} const cmdStr = this.buildArgs()
run = () => { const outputPath =
const cmdStr = this.buildCommand() this.params.outputDir ??
path.join(path.dirname(this.params.input), "hls")
const outputFile = path.join(outputPath, this.params.outputMasterName)
console.log(cmdStr) this.emit("start", {
input: this.params.input,
output: outputPath,
params: this.params,
})
const cwd = `${path.dirname(this.input)}/hls` if (!fs.existsSync(outputPath)) {
fs.mkdirSync(outputPath, { recursive: true })
}
if (!fs.existsSync(cwd)) { const inputProbe = await Utils.probe(this.params.input)
fs.mkdirSync(cwd, { recursive: true })
}
console.log(`[HLS] Started multiquality transcode`, { try {
input: this.input, const result = await this.ffmpeg({
cwd: cwd, args: cmdStr,
}) cwd: outputPath,
onProcess: (process) => {
this.handleProgress(
process.stdout,
parseFloat(inputProbe.format.duration),
(progress) => {
this.emit("progress", progress)
},
)
},
})
const process = exec( this.emit("end", {
cmdStr, outputPath: outputPath,
{ outputFile: outputFile,
cwd: cwd, })
},
(error, stdout, stderr) => {
if (error) {
console.log(`[HLS] Failed to transcode >`, error)
return this.events.emit("error", error) return result
} } catch (err) {
return this.emit("error", err)
if (stderr) { }
//return this.events.emit("error", stderr) }
}
console.log(`[HLS] Finished transcode >`, cwd)
return this.events.emit("end", {
filepath: path.join(cwd, this.outputMasterName),
isDirectory: true,
})
}
)
process.stdout.on("data", (data) => {
console.log(data.toString())
})
}
on = (key, cb) => {
this.events.on(key, cb)
return this
}
} }

View File

@ -1,79 +1,72 @@
import Redis from "ioredis" import Redis from "ioredis"
export function composeURL({ export function composeURL({ host, port, username, password } = {}) {
host, let url = "redis://"
port,
username,
password,
} = {}) {
let url = "redis://"
if (username && password) { if (username && password) {
url += username + ":" + password + "@" url += username + ":" + password + "@"
} }
url += host ?? "localhost" url += host ?? "localhost"
if (port) { if (port) {
url += ":" + port url += ":" + port
} }
return url return url
} }
export default () => { export default (params = {}) => {
let { REDIS_HOST, REDIS_PORT, REDIS_NO_AUTH, REDIS_AUTH, REDIS_DB } = process.env let { REDIS_HOST, REDIS_PORT, REDIS_NO_AUTH, REDIS_AUTH, REDIS_DB } =
process.env
REDIS_NO_AUTH = ToBoolean(REDIS_NO_AUTH) let clientOptions = {
host: REDIS_HOST ?? "localhost",
port: REDIS_PORT ?? 6379,
lazyConnect: true,
autoConnect: false,
...params,
}
let clientOptions = { // if redis auth is provided, set username and password
host: REDIS_HOST, if (!ToBoolean(REDIS_NO_AUTH) && REDIS_AUTH) {
port: REDIS_PORT, const [user, password] = REDIS_AUTH.split(":")
lazyConnect: true,
autoConnect: false
}
if (!REDIS_NO_AUTH) { clientOptions.username = user
if (REDIS_AUTH) { clientOptions.password = password
const [user, password] = REDIS_AUTH.split(":") } else {
console.log("⚠️ Redis auth is disabled")
}
clientOptions.username = user // if redis db is provided, set db
clientOptions.password = password if (REDIS_DB) {
} clientOptions.db = REDIS_DB
} else { }
console.log("⚠️ Redis auth is disabled")
}
if (REDIS_DB) { let client = new Redis(clientOptions)
clientOptions.db = REDIS_DB
}
clientOptions = composeURL(clientOptions) client.on("error", (error) => {
console.error("❌ Redis client error:", error)
})
let client = new Redis(clientOptions.host, clientOptions.port, clientOptions) client.on("connect", () => {
console.log(`✅ Redis client connected [${process.env.REDIS_HOST}]`)
})
client.on("error", (error) => { client.on("reconnecting", () => {
console.error("❌ Redis client error:", error) console.log("🔄 Redis client reconnecting...")
}) })
client.on("connect", () => { const initialize = async () => {
console.log(`✅ Redis client connected [${process.env.REDIS_HOST}]`) return await new Promise((resolve, reject) => {
}) console.log(`🔌 Connecting to Redis client [${REDIS_HOST}]`)
client.on("reconnecting", () => { client.connect(resolve)
console.log("🔄 Redis client reconnecting...") })
}) }
const initialize = async () => { return {
return await new Promise((resolve, reject) => { client,
console.log(`🔌 Connecting to Redis client [${REDIS_HOST}]`) initialize,
}
client.connect(resolve)
})
}
return {
client,
initialize
}
} }

View File

@ -1,112 +1,108 @@
import fs from "node:fs" import fs from "node:fs"
import path from "node:path" import path from "node:path"
import { exec } from "node:child_process"
import { EventEmitter } from "node:events"
export default class SegmentedAudioMPDJob { import { FFMPEGLib, Utils } from "../FFMPEGLib"
constructor({
input,
outputDir,
outputMasterName = "master.mpd",
audioCodec = "aac", export default class SegmentedAudioMPDJob extends FFMPEGLib {
audioBitrate = undefined, constructor(params = {}) {
audioSampleRate = undefined, super()
segmentTime = 10,
}) {
this.input = input
this.outputDir = outputDir
this.outputMasterName = outputMasterName
this.audioCodec = audioCodec this.params = {
this.audioBitrate = audioBitrate outputMasterName: "master.mpd",
this.segmentTime = segmentTime audioCodec: "libopus",
this.audioSampleRate = audioSampleRate audioBitrate: "320k",
audioSampleRate: "48000",
segmentTime: 10,
includeMetadata: true,
...params,
}
}
this.bin = require("ffmpeg-static") buildSegmentationArgs = () => {
const args = [
//`-threads 1`, // limits to one thread
`-v error -hide_banner -progress pipe:1`,
`-i ${this.params.input}`,
`-c:a ${this.params.audioCodec}`,
`-map 0:a`,
`-f dash`,
`-dash_segment_type mp4`,
`-segment_time ${this.params.segmentTime}`,
`-use_template 1`,
`-use_timeline 1`,
`-init_seg_name "init.m4s"`,
]
return this if (this.params.includeMetadata === false) {
} args.push(`-map_metadata -1`)
}
events = new EventEmitter() if (
typeof this.params.audioBitrate === "string" &&
this.params.audioBitrate !== "default"
) {
args.push(`-b:a ${this.params.audioBitrate}`)
}
buildCommand = () => { if (
const cmdStr = [ typeof this.params.audioSampleRate !== "undefined" &&
this.bin, this.params.audioSampleRate !== "default"
`-v quiet -stats`, ) {
`-i ${this.input}`, args.push(`-ar ${this.params.audioSampleRate}`)
`-c:a ${this.audioCodec}`, }
`-map 0:a`,
`-map_metadata -1`,
`-f dash`,
`-dash_segment_type mp4`,
`-segment_time ${this.segmentTime}`,
`-use_template 1`,
`-use_timeline 1`,
`-init_seg_name "init.m4s"`,
]
if (typeof this.audioBitrate !== "undefined") { args.push(this.params.outputMasterName)
cmdStr.push(`-b:a ${this.audioBitrate}`)
}
if (typeof this.audioSampleRate !== "undefined") { return args
cmdStr.push(`-ar ${this.audioSampleRate}`) }
}
cmdStr.push(this.outputMasterName) run = async () => {
const segmentationCmd = this.buildSegmentationArgs()
const outputPath =
this.params.outputDir ?? `${path.dirname(this.params.input)}/dash`
const outputFile = path.join(outputPath, this.params.outputMasterName)
return cmdStr.join(" ") this.emit("start", {
} input: this.params.input,
output: outputPath,
params: this.params,
})
run = () => { if (!fs.existsSync(outputPath)) {
const cmdStr = this.buildCommand() fs.mkdirSync(outputPath, { recursive: true })
}
console.log(cmdStr) const inputProbe = await Utils.probe(this.params.input)
const cwd = `${path.dirname(this.input)}/dash` try {
const result = await this.ffmpeg({
args: segmentationCmd,
onProcess: (process) => {
this.handleProgress(
process.stdout,
parseFloat(inputProbe.format.duration),
(progress) => {
this.emit("progress", progress)
},
)
},
cwd: outputPath,
})
if (!fs.existsSync(cwd)) { let outputProbe = await Utils.probe(outputFile)
fs.mkdirSync(cwd, { recursive: true })
}
console.log(`[DASH] Started audio segmentation`, { this.emit("end", {
input: this.input, probe: {
cwd: cwd, input: inputProbe,
}) output: outputProbe,
},
outputPath: outputPath,
outputFile: outputFile,
})
const process = exec( return result
cmdStr, } catch (err) {
{ return this.emit("error", err)
cwd: cwd, }
}, }
(error, stdout, stderr) => {
if (error) {
console.log(`[DASH] Failed to segment audio >`, error)
return this.events.emit("error", error)
}
if (stderr) {
//return this.events.emit("error", stderr)
}
console.log(`[DASH] Finished segmenting audio >`, cwd)
return this.events.emit("end", {
filepath: path.join(cwd, this.outputMasterName),
isDirectory: true,
})
}
)
process.stdout.on("data", (data) => {
console.log(data.toString())
})
}
on = (key, cb) => {
this.events.on(key, cb)
return this
}
} }

View File

@ -1,106 +1,118 @@
const Minio = require("minio") import path from "node:path"
import path from "path" import { Client } from "minio"
export const generateDefaultBucketPolicy = (payload) => { export const generateDefaultBucketPolicy = (payload) => {
const { bucketName } = payload const { bucketName } = payload
if (!bucketName) { if (!bucketName) {
throw new Error("bucketName is required") throw new Error("bucketName is required")
} }
return { return {
Version: "2012-10-17", Version: "2012-10-17",
Statement: [ Statement: [
{ {
Action: [ Action: ["s3:GetObject"],
"s3:GetObject" Effect: "Allow",
], Principal: {
Effect: "Allow", AWS: ["*"],
Principal: { },
AWS: [ Resource: [`arn:aws:s3:::${bucketName}/*`],
"*" Sid: "",
] },
}, ],
Resource: [ }
`arn:aws:s3:::${bucketName}/*`
],
Sid: ""
}
]
}
} }
export class StorageClient extends Minio.Client { export class StorageClient extends Client {
constructor(options) { constructor(options) {
super(options) super(options)
this.defaultBucket = String(options.defaultBucket) this.defaultBucket = String(options.defaultBucket)
this.defaultRegion = String(options.defaultRegion) this.defaultRegion = String(options.defaultRegion)
} this.setupBucket = Boolean(options.setupBucket)
this.cdnUrl = options.cdnUrl
}
composeRemoteURL = (key, extraKey) => { composeRemoteURL = (key, extraKey) => {
let _path = path.join(this.defaultBucket, key) let _path = path.join(this.defaultBucket, key)
if (typeof extraKey === "string") { if (typeof extraKey === "string") {
_path = path.join(_path, extraKey) _path = path.join(_path, extraKey)
} }
return `${this.protocol}//${this.host}:${this.port}/${_path}` if (this.cdnUrl) {
} return `${this.cdnUrl}/${_path}`
}
setDefaultBucketPolicy = async (bucketName) => { return `${this.protocol}//${this.host}:${this.port}/${_path}`
const policy = generateDefaultBucketPolicy({ bucketName }) }
return this.setBucketPolicy(bucketName, JSON.stringify(policy)) setDefaultBucketPolicy = async (bucketName) => {
} const policy = generateDefaultBucketPolicy({ bucketName })
initialize = async () => { return this.setBucketPolicy(bucketName, JSON.stringify(policy))
console.log("🔌 Checking if storage client have default bucket...") }
try { initialize = async () => {
const bucketExists = await this.bucketExists(this.defaultBucket) console.log("🔌 Checking if storage client have default bucket...")
if (!bucketExists) { if (this.setupBucket !== false) {
console.warn("🪣 Default bucket not exists! Creating new bucket...") try {
const bucketExists = await this.bucketExists(this.defaultBucket)
await this.makeBucket(this.defaultBucket, "s3") if (!bucketExists) {
console.warn(
"🪣 Default bucket not exists! Creating new bucket...",
)
// set default bucket policy await this.makeBucket(this.defaultBucket, "s3")
await this.setDefaultBucketPolicy(this.defaultBucket)
}
} catch (error) {
console.error(`Failed to check if default bucket exists or create default bucket >`, error)
}
try { // set default bucket policy
// check if default bucket policy exists await this.setDefaultBucketPolicy(this.defaultBucket)
const bucketPolicy = await this.getBucketPolicy(this.defaultBucket).catch(() => { }
return null } catch (error) {
}) console.error(
`Failed to check if default bucket exists or create default bucket >`,
error,
)
}
if (!bucketPolicy) { try {
// set default bucket policy // check if default bucket policy exists
await this.setDefaultBucketPolicy(this.defaultBucket) const bucketPolicy = await this.getBucketPolicy(
} this.defaultBucket,
} catch (error) { ).catch(() => {
console.error(`Failed to get or set default bucket policy >`, error) return null
} })
console.log("✅ Storage client is ready.") if (!bucketPolicy) {
} // set default bucket policy
await this.setDefaultBucketPolicy(this.defaultBucket)
}
} catch (error) {
console.error(
`Failed to get or set default bucket policy >`,
error,
)
}
}
console.log("✅ Storage client is ready.")
}
} }
export const createStorageClientInstance = (options) => { export const createStorageClientInstance = (options) => {
return new StorageClient({ return new StorageClient({
endPoint: process.env.S3_ENDPOINT, endPoint: process.env.S3_ENDPOINT,
port: Number(process.env.S3_PORT), port: Number(process.env.S3_PORT),
useSSL: ToBoolean(process.env.S3_USE_SSL), useSSL: ToBoolean(process.env.S3_USE_SSL),
accessKey: process.env.S3_ACCESS_KEY, accessKey: process.env.S3_ACCESS_KEY,
secretKey: process.env.S3_SECRET_KEY, secretKey: process.env.S3_SECRET_KEY,
defaultBucket: process.env.S3_BUCKET, defaultBucket: process.env.S3_BUCKET,
defaultRegion: process.env.S3_REGION, defaultRegion: process.env.S3_REGION,
...options, ...options,
}) })
} }
export default createStorageClientInstance export default createStorageClientInstance

View File

@ -56,14 +56,8 @@ export default class TaskQueueManager {
registerQueueEvents = (worker) => { registerQueueEvents = (worker) => {
worker.on("progress", (job, progress) => { worker.on("progress", (job, progress) => {
try { try {
console.log(`Job ${job.id} reported progress: ${progress}%`)
if (job.data.sseChannelId) { if (job.data.sseChannelId) {
global.sse.sendToChannel(job.data.sseChannelId, { global.sse.sendToChannel(job.data.sseChannelId, progress)
status: "progress",
events: "job_progress",
progress,
})
} }
} catch (error) { } catch (error) {
// manejar error // manejar error
@ -76,8 +70,9 @@ export default class TaskQueueManager {
if (job.data.sseChannelId) { if (job.data.sseChannelId) {
global.sse.sendToChannel(job.data.sseChannelId, { global.sse.sendToChannel(job.data.sseChannelId, {
status: "done", event: "done",
result, state: "done",
result: result,
}) })
} }
} catch (error) {} } catch (error) {}
@ -89,7 +84,8 @@ export default class TaskQueueManager {
if (job.data.sseChannelId) { if (job.data.sseChannelId) {
global.sse.sendToChannel(job.data.sseChannelId, { global.sse.sendToChannel(job.data.sseChannelId, {
status: "error", event: "error",
state: "error",
result: error.message, result: error.message,
}) })
} }
@ -122,9 +118,9 @@ export default class TaskQueueManager {
) )
await global.sse.sendToChannel(sseChannelId, { await global.sse.sendToChannel(sseChannelId, {
status: "progress", event: "job_queued",
events: "job_queued", state: "progress",
progress: 5, percent: 5,
}) })
} }

View File

@ -0,0 +1,33 @@
import path from "node:path"
import SegmentedAudioMPDJob from "@shared-classes/SegmentedAudioMPDJob"
export default async ({ filePath, workPath, onProgress }) => {
return new Promise(async (resolve, reject) => {
const outputDir = path.resolve(workPath, "a-dash")
const job = new SegmentedAudioMPDJob({
input: filePath,
outputDir: outputDir,
// set to default as raw flac
audioCodec: "flac",
audioBitrate: "default",
audioSampleRate: "default",
})
job.on("end", (data) => {
resolve(data)
})
job.on("progress", (progress) => {
if (typeof onProgress === "function") {
onProgress({
percent: progress,
state: "transmuxing",
})
}
})
job.run()
})
}

View File

@ -0,0 +1,45 @@
import path from "node:path"
import MultiqualityHLSJob from "@shared-classes/MultiqualityHLSJob"
export default async ({ filePath, workPath, onProgress }) => {
return new Promise(async (resolve, reject) => {
const outputDir = path.resolve(workPath, "mqhls")
const job = new MultiqualityHLSJob({
input: filePath,
outputDir: outputDir,
// set default
outputMasterName: "master.m3u8",
levels: [
{
original: true,
codec: "libx264",
bitrate: "10M",
preset: "ultrafast",
},
{
codec: "libx264",
width: 1280,
bitrate: "3M",
preset: "ultrafast",
},
],
})
job.on("end", (data) => {
resolve(data)
})
job.on("progress", (progress) => {
if (typeof onProgress === "function") {
onProgress({
percent: progress,
state: "transmuxing",
})
}
})
job.run()
})
}

View File

@ -0,0 +1,63 @@
import fs from "node:fs"
import path from "node:path"
import { fileTypeFromBuffer } from "file-type"
import readChunk from "@shared-utils/readChunk"
import Sharp from "sharp"
const thresholds = {
size: 10 * 1024 * 1024,
}
const sharpConfigs = {
png: {
compressionLevel: 6,
//quality: 80,
},
jpeg: {
quality: 80,
mozjpeg: true,
},
default: {
quality: 80,
},
}
export default async ({ filePath, workPath, onProgress }) => {
const stat = await fs.promises.stat(filePath)
const firstBuffer = await readChunk(filePath, {
length: 4100,
})
const fileType = await fileTypeFromBuffer(firstBuffer)
// first check if size over threshold
if (stat.size < thresholds.size) {
return {
outputFile: filePath,
}
}
// get the type of the file mime
const type = fileType.mime.split("/")[0]
switch (type) {
case "image": {
let image = Sharp(filePath)
const metadata = await image.metadata()
const config = sharpConfigs[metadata.format] ?? sharpConfigs.default
image = await image[metadata.format](config).withMetadata()
filePath = path.resolve(workPath, `${path.basename(filePath)}_ff`)
await image.toFile(filePath)
}
}
return {
outputFile: filePath,
}
}

View File

@ -0,0 +1,26 @@
const Handlers = {
"a-dash": require("./handlers/a-dash").default,
"mq-hls": require("./handlers/mq-hls").default,
optimize: require("./handlers/optimize").default,
}
export type TransformationPayloadType = {
filePath: string
workPath: string
handler: string
onProgress?: function
}
class Transformation {
static async transform(payload: TransformationPayloadType) {
const handler = Handlers[payload.handler]
if (typeof handler !== "function") {
throw new Error(`Invalid handler: ${payload.handler}`)
}
return await handler(payload)
}
}
export default Transformation

View File

@ -0,0 +1,154 @@
import fs from "node:fs"
import path from "node:path"
import { fileTypeFromBuffer } from "file-type"
import readChunk from "@shared-utils/readChunk"
import getFileHash from "@shared-utils/readFileHash"
import putObject from "./putObject"
import Transformation from "../Transformation"
export type FileHandlePayload = {
user_id: string
filePath: string
workPath: string
targetPath?: string // mostly provided by processed results
//uploadId?: string
transformations?: Array<string>
useCompression?: boolean
s3Provider?: string
onProgress?: Function
}
export type S3UploadPayload = {
filePath: string
basePath: string
targetPath?: string
s3Provider?: string
onProgress?: Function
}
export default class Upload {
static fileHandle = async (payload: FileHandlePayload) => {
if (!payload.transformations) {
payload.transformations = []
}
// if compression is enabled and no transformations are provided, add basic transformations for images or videos
if (
payload.useCompression === true &&
payload.transformations.length === 0
) {
payload.transformations.push("optimize")
}
// process file upload if transformations are provided
if (payload.transformations.length > 0) {
// process
const processed = await Upload.transform(payload)
// overwrite filePath
payload.filePath = processed.filePath
}
// upload
const result = await Upload.toS3({
filePath: payload.filePath,
targetPath: payload.targetPath,
basePath: payload.user_id,
onProgress: payload.onProgress,
s3Provider: payload.s3Provider,
})
// delete workpath
await fs.promises.rm(payload.workPath, { recursive: true, force: true })
return result
}
static transform = async (payload: FileHandlePayload) => {
if (Array.isArray(payload.transformations)) {
for await (const transformation of payload.transformations) {
const transformationResult = await Transformation.transform({
filePath: payload.filePath,
workPath: payload.workPath,
onProgress: payload.onProgress,
handler: transformation,
})
// if is a file, overwrite filePath
if (transformationResult.outputFile) {
payload.filePath = transformationResult.outputFile
}
// if is a directory, overwrite filePath to upload entire directory
if (transformationResult.outputPath) {
payload.filePath = transformationResult.outputPath
payload.targetPath = transformationResult.outputFile
//payload.isDirectory = true
}
}
}
return payload
}
static toS3 = async (payload: S3UploadPayload) => {
const { filePath, basePath, targetPath, s3Provider, onProgress } =
payload
// if targetPath is provided, means its a directory
const isDirectory = !!targetPath
const metadata = await this.buildFileMetadata(
isDirectory ? targetPath : filePath,
)
let uploadPath = path.join(basePath, metadata["File-Hash"])
if (isDirectory) {
uploadPath = path.join(basePath, global.nanoid())
}
if (typeof onProgress === "function") {
onProgress({
percent: 0,
state: "uploading_s3",
})
}
// console.log("Uploading to S3:", {
// filePath: filePath,
// basePath: basePath,
// uploadPath: uploadPath,
// targetPath: targetPath,
// metadata: metadata,
// s3Provider: s3Provider,
// })
const result = await putObject({
filePath: filePath,
uploadPath: uploadPath,
metadata: metadata,
targetFilename: isDirectory ? path.basename(targetPath) : null,
provider: s3Provider,
})
return result
}
static async buildFileMetadata(filePath: string) {
const firstBuffer = await readChunk(filePath, {
length: 4100,
})
const fileHash = await getFileHash(fs.createReadStream(filePath))
const fileType = await fileTypeFromBuffer(firstBuffer)
const metadata = {
"File-Hash": fileHash,
"Content-Type": fileType?.mime ?? "application/octet-stream",
}
return metadata
}
}

View File

@ -0,0 +1,65 @@
import fs from "node:fs"
import path from "node:path"
import pMap from "p-map"
export default async function putObject({
filePath,
uploadPath,
metadata = {},
targetFilename,
onFinish,
provider = "standard",
}) {
const providerClass = global.storages[provider]
if (!providerClass) {
throw new Error(`Provider [${provider}] not found`)
}
const isDirectory = await fs.promises
.lstat(filePath)
.then((stats) => stats.isDirectory())
if (isDirectory) {
let files = await fs.promises.readdir(filePath)
files = files.map((file) => {
const newPath = path.join(filePath, file)
return {
filePath: newPath,
uploadPath: path.join(uploadPath, file),
}
})
await pMap(files, putObject, {
concurrency: 3,
})
return {
id: uploadPath,
url: providerClass.composeRemoteURL(uploadPath, targetFilename),
metadata: metadata,
}
}
// upload to storage
await providerClass.fPutObject(
process.env.S3_BUCKET,
uploadPath,
filePath,
metadata,
)
const result = {
id: uploadPath,
url: providerClass.composeRemoteURL(uploadPath),
metadata: metadata,
}
if (typeof onFinish === "function") {
await onFinish(result)
}
return result
}

View File

@ -1,35 +1,36 @@
export default { export default {
name: "MusicRelease", name: "MusicRelease",
collection: "music_releases", collection: "music_releases",
schema: { schema: {
user_id: { user_id: {
type: String, type: String,
required: true required: true,
}, },
title: { title: {
type: String, type: String,
required: true required: true,
}, },
type: { type: {
type: String, type: String,
required: true, required: true,
}, },
list: { items: {
type: Object, type: Array,
default: [], default: [],
required: true required: true,
}, },
cover: { cover: {
type: String, type: String,
default: "https://storage.ragestudio.net/comty-static-assets/default_song.png" default:
}, "https://storage.ragestudio.net/comty-static-assets/default_song.png",
created_at: { },
type: Date, created_at: {
required: true type: Date,
}, required: true,
public: { },
type: Boolean, public: {
default: true, type: Boolean,
}, default: true,
} },
},
} }

View File

@ -1,46 +1,43 @@
export default { export default {
name: "Track", name: "Track",
collection: "tracks", collection: "tracks",
schema: { schema: {
source: { source: {
type: String, type: String,
required: true, required: true,
}, },
title: { title: {
type: String, type: String,
required: true, required: true,
}, },
album: { album: {
type: String, type: String,
}, },
artists: { artist: {
type: Array, type: String,
}, },
metadata: { metadata: {
type: Object, type: Object,
}, },
explicit: { explicit: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
public: { public: {
type: Boolean, type: Boolean,
default: true, default: true,
}, },
publish_date: { publish_date: {
type: Date, type: Date,
}, },
cover: { cover: {
type: String, type: String,
default: "https://storage.ragestudio.net/comty-static-assets/default_song.png" default:
}, "https://storage.ragestudio.net/comty-static-assets/default_song.png",
publisher: { },
type: Object, publisher: {
required: true, type: Object,
}, required: true,
lyrics_enabled: { },
type: Boolean, },
default: false
}
}
} }

View File

@ -1,77 +1,76 @@
export default { export default {
name: "User", name: "User",
collection: "accounts", collection: "accounts",
schema: { schema: {
username: { username: {
type: String, type: String,
required: true required: true,
}, },
password: { password: {
type: String, type: String,
required: true, required: true,
select: false select: false,
}, },
email: { email: {
type: String, type: String,
required: true, required: true,
select: false select: false,
}, },
description: { description: {
type: String, type: String,
default: null default: null,
}, },
created_at: { created_at: {
type: String type: String,
}, },
public_name: { public_name: {
type: String, type: String,
default: null default: null,
}, },
cover: { cover: {
type: String, type: String,
default: null default: null,
}, },
avatar: { avatar: {
type: type: String,
String, default: null,
default: null },
}, roles: {
roles: { type: Array,
type: Array, default: [],
default: [] },
}, verified: {
verified: { type: Boolean,
type: Boolean, default: false,
default: false },
}, badges: {
badges: { type: Array,
type: Array, default: [],
default: [] },
}, links: {
links: { type: Array,
type: Array, default: [],
default: [] },
}, location: {
location: { type: String,
type: String, default: null,
default: null },
}, birthday: {
birthday: { type: Date,
type: Date, default: null,
default: null, select: false,
select: false },
}, accept_tos: {
accept_tos: { type: Boolean,
type: Boolean, default: false,
default: false },
}, activated: {
activated: { type: Boolean,
type: Boolean, default: false,
default: false, },
}, disabled: {
disabled: { type: Boolean,
type: Boolean, default: false,
default: false },
}, },
}
} }

2
packages/server/dev.sh Executable file
View File

@ -0,0 +1,2 @@
#!/bin/bash
node --run dev

View File

@ -181,19 +181,11 @@ export default class Gateway {
serviceId: id, serviceId: id,
path: path, path: path,
target: `${http.proto}://${listen.ip}:${listen.port}${path}`, target: `${http.proto}://${listen.ip}:${listen.port}${path}`,
websocket: !!websocket,
}) })
} }
} }
if (websocket && websocket.enabled === true) {
await this.gateway.register({
serviceId: id,
websocket: true,
path: websocket.path,
target: `${http.proto}://${listen.ip}:${listen.port}${websocket.path}`,
})
}
if (this.state.allReady) { if (this.state.allReady) {
if (typeof this.gateway.applyConfiguration === "function") { if (typeof this.gateway.applyConfiguration === "function") {
await this.gateway.applyConfiguration() await this.gateway.applyConfiguration()

View File

@ -6,6 +6,7 @@ import defaults from "linebridge/dist/defaults"
const localNginxBinary = path.resolve(process.cwd(), "nginx-bin") const localNginxBinary = path.resolve(process.cwd(), "nginx-bin")
const serverPkg = require("../../../package.json") const serverPkg = require("../../../package.json")
/** /**
* NginxManager - Optimized version that batches configurations * NginxManager - Optimized version that batches configurations
* Waits for all services to register before applying configuration * Waits for all services to register before applying configuration
@ -253,7 +254,7 @@ http {
if (debugFlag) { if (debugFlag) {
console.log( console.log(
`🔍 Registering route for [${serviceId}]: ${normalizedPath} -> ${target} (${websocket ? "WebSocket" : "HTTP"})`, `🔍 Registering route for [${serviceId}]: ${normalizedPath} -> ${target}`,
) )
} }
@ -261,8 +262,8 @@ http {
const effectivePathRewrite = pathRewrite || {} const effectivePathRewrite = pathRewrite || {}
this.routes.set(normalizedPath, { this.routes.set(normalizedPath, {
serviceId, serviceId: serviceId,
target, target: target,
pathRewrite: effectivePathRewrite, pathRewrite: effectivePathRewrite,
websocket: !!websocket, websocket: !!websocket,
}) })

View File

@ -55,7 +55,8 @@ export default class Service {
this.instance = await spawnService({ this.instance = await spawnService({
id: this.id, id: this.id,
service: this.path, service: this,
path: this.path,
cwd: this.cwd, cwd: this.cwd,
onClose: this.handleClose.bind(this), onClose: this.handleClose.bind(this),
onError: this.handleError.bind(this), onError: this.handleError.bind(this),
@ -140,8 +141,7 @@ export default class Service {
// Kill the current process if is running // Kill the current process if is running
if (this.instance.exitCode === null) { if (this.instance.exitCode === null) {
console.log(`[${this.id}] Killing current process...`) await this.instance.kill()
await this.instance.kill("SIGKILL")
} }
// Start a new process // Start a new process
@ -153,17 +153,13 @@ export default class Service {
/** /**
* Stop the service * Stop the service
*/ */
async stop() { stop() {
console.log(`[${this.id}] Stopping service...`) console.log(`[${this.id}] Stopping service...`)
if (this.fileWatcher) { this.instance.kill()
await this.fileWatcher.close()
this.fileWatcher = null
}
if (this.instance) { if (this.fileWatcher) {
await this.instance.kill("SIGKILL") this.fileWatcher.close()
this.instance = null
} }
} }

View File

@ -3,21 +3,29 @@ import createServiceLogTransformer from "./createServiceLogTransformer"
import Vars from "../vars" import Vars from "../vars"
export default async ({ id, service, cwd, onClose, onError, onIPCData }) => { export default async ({
id,
service,
path,
cwd,
onClose,
onError,
onIPCData,
}) => {
const instanceEnv = { const instanceEnv = {
...process.env, ...process.env,
lb_service: { lb_service_id: service.id,
id: service.id, lb_service_path: service.path,
index: service.index, lb_service_version: service.version,
}, lb_service_cwd: service.cwd,
lb_service: true,
} }
let instance = ChildProcess.fork(Vars.bootloaderBin, [service], { let instance = ChildProcess.fork(Vars.bootloaderBin, [path], {
detached: false, detached: false,
silent: true, silent: true,
cwd: cwd, cwd: cwd,
env: instanceEnv, env: instanceEnv,
killSignal: "SIGTERM",
}) })
instance.logs = { instance.logs = {

View File

@ -1,6 +1,6 @@
{ {
"name": "@comty/server", "name": "@comty/server",
"version": "1.31.0@alpha", "version": "1.38.0@alpha",
"license": "ComtyLicense", "license": "ComtyLicense",
"private": true, "private": true,
"workspaces": [ "workspaces": [

View File

@ -0,0 +1,165 @@
#!/bin/bash
GREEN='\033[0;32m'
RED='\033[0;31m'
YELLOW='\033[0;33m'
NC='\033[0m'
detect_os() {
if [[ "$OSTYPE" == "linux-gnu"* ]]; then
echo "linux"
else
echo "unsupported"
fi
}
detect_arch() {
ARCH=$(uname -m)
if [[ "$ARCH" == "x86_64" ]]; then
echo "amd64"
elif [[ "$ARCH" == "aarch64" ]] || [[ "$ARCH" == "arm64" ]]; then
echo "arm64"
elif [[ "$ARCH" == "armv7l" ]]; then
echo "armhf"
else
echo "unsupported"
fi
}
OS=$(detect_os)
ARCH=$(detect_arch)
if [[ "$OS" == "unsupported" ]] || [[ "$ARCH" == "unsupported" ]]; then
echo -e "${RED}Operating system or architecture not supported. This script only supports Linux on amd64, arm64, or armhf architectures.${NC}"
exit 1
fi
INSTALL_DIR="$HOME/.local/bin"
TEMP_DIR="/tmp/ffmpegdl"
if [[ -d "$TEMP_DIR" ]]; then
rm -rf "$TEMP_DIR"
fi
mkdir -p "$TEMP_DIR"
if [[ ! -d "$INSTALL_DIR" ]]; then
echo -e "${RED}$INSTALL_DIR is not a directory.${NC}"
exit 1
fi
download_ffmpeg() {
echo -e "${YELLOW}Downloading the latest stable version of FFmpeg...${NC}"
# Base URL for downloads from John van Sickle's FFmpeg builds
BASE_URL="https://johnvansickle.com/ffmpeg/releases"
# Map architecture to the expected format in the URL
if [[ "$ARCH" == "amd64" ]]; then
URL_ARCH="amd64"
elif [[ "$ARCH" == "arm64" ]]; then
URL_ARCH="arm64"
elif [[ "$ARCH" == "armhf" ]]; then
URL_ARCH="armhf"
fi
# Create the download URL for the latest release
FFMPEG_URL="$BASE_URL/ffmpeg-release-$URL_ARCH-static.tar.xz"
if [[ -z "$FFMPEG_URL" ]]; then
echo -e "${RED}Could not determine the download URL for your system.${NC}"
exit 1
fi
# Download the file
ARCHIVE_FILE="$TEMP_DIR/ffmpeg.tar.xz"
echo -e "${YELLOW}Downloading from: $FFMPEG_URL${NC}"
if command -v wget > /dev/null; then
wget -q --show-progress -O "$ARCHIVE_FILE" "$FFMPEG_URL"
elif command -v curl > /dev/null; then
curl -L -o "$ARCHIVE_FILE" "$FFMPEG_URL"
else
echo -e "${RED}wget or curl is required to download FFmpeg.${NC}"
exit 1
fi
if [[ $? -ne 0 ]]; then
echo -e "${RED}Error downloading FFmpeg.${NC}"
exit 1
fi
echo -e "${GREEN}Download completed.${NC}"
# Extract the file
echo -e "${YELLOW}Extracting files...${NC}"
cd "$TEMP_DIR"
tar -xf "$ARCHIVE_FILE"
if [[ $? -ne 0 ]]; then
echo -e "${RED}Error extracting the file.${NC}"
exit 1
fi
echo -e "${GREEN}Extraction completed.${NC}"
# Clean up downloaded file
rm "$ARCHIVE_FILE"
}
install_binaries() {
echo -e "${YELLOW}Installing binaries...${NC}"
# Find the extracted directory
EXTRACTED_DIR=$(find "$TEMP_DIR" -maxdepth 1 -type d -name "ffmpeg-*" | head -n 1)
if [[ -z "$EXTRACTED_DIR" ]]; then
echo -e "${RED}FFmpeg extracted directory not found.${NC}"
exit 1
fi
# Find the binaries
FFMPEG_BIN="$EXTRACTED_DIR/ffmpeg"
FFPROBE_BIN="$EXTRACTED_DIR/ffprobe"
# Verify binaries exist
if [[ ! -f "$FFMPEG_BIN" ]] || [[ ! -f "$FFPROBE_BIN" ]]; then
echo -e "${RED}FFmpeg and FFprobe binaries not found.${NC}"
exit 1
fi
# Copy binaries to the bin folder
mv "$FFMPEG_BIN" "$INSTALL_DIR/ffmpeg"
mv "$FFPROBE_BIN" "$INSTALL_DIR/ffprobe"
# Make binaries executable
chmod +x "$INSTALL_DIR/ffmpeg"
chmod +x "$INSTALL_DIR/ffprobe"
echo -e "${GREEN}Binaries installed in $INSTALL_DIR${NC}"
# Clean up extracted directory
rm -rf "$EXTRACTED_DIR"
rm -rf "$TEMP_DIR"
}
show_versions() {
echo -e "${YELLOW}Verifying the installation...${NC}"
FFMPEG_PATH="$INSTALL_DIR/ffmpeg"
FFPROBE_PATH="$INSTALL_DIR/ffprobe"
echo -e "${GREEN}FFmpeg installed at: $FFMPEG_PATH${NC}"
if [[ -x "$FFMPEG_PATH" ]]; then
"$FFMPEG_PATH" -version | head -n 1
fi
echo -e "${GREEN}FFprobe installed at: $FFPROBE_PATH${NC}"
if [[ -x "$FFPROBE_PATH" ]]; then
"$FFPROBE_PATH" -version | head -n 1
fi
}
download_ffmpeg
install_binaries
show_versions

View File

@ -2,31 +2,25 @@ import bcrypt from "bcrypt"
import { User } from "@db_models" import { User } from "@db_models"
export default async ({ username, password, hash }, user) => { export default async ({ username, password, hash }, user) => {
if (typeof user === "undefined") { if (typeof user === "undefined") {
let isEmail = username.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/) let isEmail = username.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)
let query = isEmail ? { email: username } : { username: username } let query = isEmail ? { email: username } : { username: username }
user = await User.findOne(query).select("+email").select("+password") user = await User.findOne(query).select("+email").select("+password")
} }
if (!user) { if (!user) {
throw new OperationError(401, "User not found") throw new OperationError(401, "User not found")
} }
if (user.disabled == true) { if (user.disabled == true) {
throw new OperationError(401, "User is disabled") throw new OperationError(401, "User is disabled")
} }
if (typeof hash !== "undefined") { if (!bcrypt.compareSync(password, user.password)) {
if (user.password !== hash) { throw new OperationError(401, "Invalid credentials")
throw new OperationError(401, "Invalid credentials") }
}
} else {
if (!bcrypt.compareSync(password, user.password)) {
throw new OperationError(401, "Invalid credentials")
}
}
return user return user
} }

View File

@ -1,4 +1,3 @@
{ {
"name": "auth", "name": "auth"
"version": "1.0.0"
} }

View File

@ -1,4 +1,3 @@
{ {
"name": "chats", "name": "chats"
"version": "0.60.2"
} }

View File

@ -3,40 +3,42 @@ import nodemailer from "nodemailer"
import DbManager from "@shared-classes/DbManager" import DbManager from "@shared-classes/DbManager"
import SharedMiddlewares from "@shared-middlewares" import SharedMiddlewares from "@shared-middlewares"
export default class API extends Server { export default class API extends Server {
static refName = "ems" static refName = "ems"
static useEngine = "hyper-express" static useEngine = "hyper-express"
static routesPath = `${__dirname}/routes` static routesPath = `${__dirname}/routes`
static listen_port = process.env.HTTP_LISTEN_PORT ?? 3007 static listen_port = process.env.HTTP_LISTEN_PORT ?? 3007
middlewares = { middlewares = {
...SharedMiddlewares ...SharedMiddlewares,
} }
contexts = { contexts = {
db: new DbManager(), db: new DbManager(),
mailTransporter: nodemailer.createTransport({ mailTransporter: nodemailer.createTransport({
host: process.env.SMTP_HOSTNAME, host: process.env.SMTP_HOSTNAME,
port: process.env.SMTP_PORT ?? 587, port: process.env.SMTP_PORT ?? 587,
secure: ToBoolean(process.env.SMTP_SECURE) ?? false, secure: ToBoolean(process.env.SMTP_SECURE) ?? false,
auth: { auth: {
user: process.env.SMTP_USERNAME, user: process.env.SMTP_USERNAME,
pass: process.env.SMTP_PASSWORD, pass: process.env.SMTP_PASSWORD,
}, },
}), }),
} }
ipcEvents = { ipcEvents = {
"account:activation:send": require("./ipcEvents/accountActivation").default, "account:activation:send": require("./ipcEvents/accountActivation")
"new:login": require("./ipcEvents/newLogin").default, .default,
"mfa:send": require("./ipcEvents/mfaSend").default, "new:login": require("./ipcEvents/newLogin").default,
"apr:send": require("./ipcEvents/aprSend").default, "mfa:send": require("./ipcEvents/mfaSend").default,
"password:changed": require("./ipcEvents/passwordChanged").default, "apr:send": require("./ipcEvents/aprSend").default,
} "password:changed": require("./ipcEvents/passwordChanged").default,
}
async onInitialize() { async onInitialize() {
await this.contexts.db.initialize() await this.contexts.db.initialize()
} }
} }
Boot(API) Boot(API)

View File

@ -1,10 +1,7 @@
{ {
"name": "ems", "name": "ems",
"description": "External Messaging Service (SMS, EMAIL, PUSH)", "dependencies": {
"version": "0.1.0", "handlebars": "^4.7.8",
"dependencies": { "nodemailer": "^6.9.11"
"handlebars": "^4.7.8", }
"nodemailer": "^6.9.11",
"web-push": "^3.6.7"
}
} }

View File

@ -104,11 +104,9 @@ export function createAssembleChunksPromise({
export async function handleChunkFile( export async function handleChunkFile(
fileStream, fileStream,
{ tmpDir, headers, maxFileSize, maxChunkSize }, { chunksPath, outputDir, headers, maxFileSize, maxChunkSize },
) { ) {
return await new Promise(async (resolve, reject) => { return await new Promise(async (resolve, reject) => {
const workPath = path.join(tmpDir, headers["uploader-file-id"])
const chunksPath = path.join(workPath, "chunks")
const chunkPath = path.join( const chunkPath = path.join(
chunksPath, chunksPath,
headers["uploader-chunk-number"], headers["uploader-chunk-number"],
@ -125,17 +123,6 @@ export async function handleChunkFile(
return reject(new OperationError(500, "Chunk is out of range")) return reject(new OperationError(500, "Chunk is out of range"))
} }
// if is the first chunk check if dir exists before write things
if (chunkCount === 0) {
try {
if (!(await fs.promises.stat(chunksPath).catch(() => false))) {
await fs.promises.mkdir(chunksPath, { recursive: true })
}
} catch (error) {
return reject(new OperationError(500, error.message))
}
}
let dataWritten = 0 let dataWritten = 0
let writeStream = fs.createWriteStream(chunkPath) let writeStream = fs.createWriteStream(chunkPath)
@ -172,25 +159,18 @@ export async function handleChunkFile(
} }
if (isLast) { if (isLast) {
const mimetype = mimetypes.lookup( // const mimetype = mimetypes.lookup(
headers["uploader-original-name"], // headers["uploader-original-name"],
) // )
const extension = mimetypes.extension(mimetype) // const extension = mimetypes.extension(mimetype)
let filename = headers["uploader-file-id"] let filename = nanoid()
if (headers["uploader-use-date"] === "true") {
filename = `${filename}_${Date.now()}`
}
return resolve( return resolve(
createAssembleChunksPromise({ createAssembleChunksPromise({
// build data // build data
chunksPath: chunksPath, chunksPath: chunksPath,
filePath: path.resolve( filePath: path.resolve(outputDir, filename),
workPath,
`${filename}.${extension}`,
),
maxFileSize: maxFileSize, maxFileSize: maxFileSize,
}), }),
) )

View File

@ -1,21 +1,21 @@
import { Server } from "linebridge" import { Server } from "linebridge"
import B2 from "backblaze-b2"
import DbManager from "@shared-classes/DbManager" import DbManager from "@shared-classes/DbManager"
import RedisClient from "@shared-classes/RedisClient"
import StorageClient from "@shared-classes/StorageClient" import StorageClient from "@shared-classes/StorageClient"
import CacheService from "@shared-classes/CacheService" import CacheService from "@shared-classes/CacheService"
import SSEManager from "@shared-classes/SSEManager" import SSEManager from "@shared-classes/SSEManager"
import SharedMiddlewares from "@shared-middlewares"
import LimitsClass from "@shared-classes/Limits" import LimitsClass from "@shared-classes/Limits"
import TaskQueueManager from "@shared-classes/TaskQueueManager" import TaskQueueManager from "@shared-classes/TaskQueueManager"
import SharedMiddlewares from "@shared-middlewares"
class API extends Server { class API extends Server {
static refName = "files" static refName = "files"
static useEngine = "hyper-express" static useEngine = "hyper-express-ng"
static routesPath = `${__dirname}/routes` static routesPath = `${__dirname}/routes`
static listen_port = process.env.HTTP_LISTEN_PORT ?? 3002 static listen_port = process.env.HTTP_LISTEN_PORT ?? 3002
static enableWebsockets = true //static enableWebsockets = true
middlewares = { middlewares = {
...SharedMiddlewares, ...SharedMiddlewares,
@ -24,10 +24,13 @@ class API extends Server {
contexts = { contexts = {
db: new DbManager(), db: new DbManager(),
cache: new CacheService(), cache: new CacheService(),
SSEManager: new SSEManager(),
redis: RedisClient({
maxRetriesPerRequest: null,
}),
limits: {},
storage: StorageClient(), storage: StorageClient(),
b2Storage: null, b2Storage: null,
SSEManager: new SSEManager(),
limits: {},
} }
queuesManager = new TaskQueueManager( queuesManager = new TaskQueueManager(
@ -41,27 +44,35 @@ class API extends Server {
global.sse = this.contexts.SSEManager global.sse = this.contexts.SSEManager
if (process.env.B2_KEY_ID && process.env.B2_APP_KEY) { if (process.env.B2_KEY_ID && process.env.B2_APP_KEY) {
this.contexts.b2Storage = new B2({ this.contexts.b2Storage = StorageClient({
applicationKeyId: process.env.B2_KEY_ID, endPoint: process.env.B2_ENDPOINT,
applicationKey: process.env.B2_APP_KEY, cdnUrl: process.env.B2_CDN_ENDPOINT,
defaultBucket: process.env.B2_BUCKET,
accessKey: process.env.B2_KEY_ID,
secretKey: process.env.B2_APP_KEY,
port: 443,
useSSL: true,
setupBucket: false,
}) })
global.b2Storage = this.contexts.b2Storage await this.contexts.b2Storage.initialize()
await this.contexts.b2Storage.authorize()
} else { } else {
console.warn( console.warn(
"B2 storage not configured on environment, skipping...", "B2 storage not configured on environment, skipping...",
) )
} }
await this.contexts.redis.initialize()
await this.queuesManager.initialize({ await this.queuesManager.initialize({
redisOptions: this.engine.ws.redis.options, redisOptions: this.contexts.redis.client,
}) })
await this.contexts.db.initialize() await this.contexts.db.initialize()
await this.contexts.storage.initialize() await this.contexts.storage.initialize()
global.storage = this.contexts.storage global.storages = {
standard: this.contexts.storage,
b2: this.contexts.b2Storage,
}
global.queues = this.queuesManager global.queues = this.queuesManager
this.contexts.limits = await LimitsClass.get() this.contexts.limits = await LimitsClass.get()

View File

@ -1,20 +1,10 @@
{ {
"name": "files", "name": "files",
"version": "0.60.2", "dependencies": {
"dependencies": { "file-type": "^20.4.1",
"backblaze-b2": "^1.7.0", "fluent-ffmpeg": "^2.1.2",
"busboy": "^1.6.0", "mime-types": "^2.1.35",
"content-range": "^2.0.2", "p-map": "4",
"ffmpeg-static": "^5.2.0", "sharp": "0.32.6"
"fluent-ffmpeg": "^2.1.2", }
"merge-files": "^0.1.2",
"mime-types": "^2.1.35",
"minio": "^7.0.32",
"normalize-url": "^8.0.0",
"p-map": "4",
"p-queue": "^7.3.4",
"redis": "^4.6.6",
"sharp": "0.32.6",
"split-chunk-merge": "^1.0.0"
}
} }

View File

@ -1,49 +0,0 @@
import path from "node:path"
import fs from "node:fs"
import RemoteUpload from "@services/remoteUpload"
export default {
id: "remote_upload",
maxJobs: 10,
process: async (job) => {
const {
filePath,
parentDir,
service,
useCompression,
cachePath,
transmux,
transmuxOptions,
} = job.data
console.log("[JOB][remote_upload] Processing job >", job.data)
try {
const result = await RemoteUpload({
parentDir: parentDir,
source: filePath,
service: service,
useCompression: useCompression,
transmux: transmux,
transmuxOptions: transmuxOptions,
cachePath: cachePath,
onProgress: (progress) => {
job.updateProgress(progress)
},
})
await fs.promises
.rm(filePath, { recursive: true, force: true })
.catch(() => null)
return result
} catch (error) {
await fs.promises
.rm(filePath, { recursive: true, force: true })
.catch(() => null)
throw error
}
},
}

View File

@ -1,43 +0,0 @@
import RemoteUpload from "@services/remoteUpload"
import fs from "node:fs"
module.exports = async (job) => {
const {
filePath,
parentDir,
service,
useCompression,
cachePath,
transmux,
transmuxOptions,
} = job.data
console.log("[JOB][remote_upload] Processing job >", job.data)
try {
const result = await RemoteUpload({
parentDir: parentDir,
source: filePath,
service: service,
useCompression: useCompression,
transmux: transmux,
transmuxOptions: transmuxOptions,
cachePath: cachePath,
onProgress: (progress) => {
job.progress(progress)
},
})
await fs.promises
.rm(filePath, { recursive: true, force: true })
.catch(() => null)
return result
} catch (error) {
await fs.promises
.rm(filePath, { recursive: true, force: true })
.catch(() => null)
throw error
}
}

View File

@ -0,0 +1,29 @@
import path from "node:path"
import fs from "node:fs"
import Upload from "@shared-classes/Upload"
export default {
id: "file-process",
maxJobs: 2,
process: async (job) => {
console.log("[JOB][file-process] starting... >", job.data)
try {
const result = await Upload.fileHandle({
...job.data,
onProgress: (progress) => {
job.updateProgress(progress)
},
})
return result
} catch (error) {
await fs.promises
.rm(job.workPath, { recursive: true, force: true })
.catch(() => null)
throw error
}
},
}

View File

@ -1,25 +0,0 @@
import mimetypes from "mime-types"
export default {
useContext: ["storage"],
fn: async (req, res) => {
const streamPath = req.path.replace(req.route.pattern.replace("*", ""), "/")
this.default.contexts.storage.getObject(process.env.S3_BUCKET, streamPath, (err, dataStream) => {
if (err) {
return res.status(404).end()
}
const extname = mimetypes.lookup(streamPath)
// send chunked response
res.status(200)
// set headers
res.setHeader("Content-Type", extname)
res.setHeader("Accept-Ranges", "bytes")
return dataStream.pipe(res)
})
}
}

View File

@ -1,88 +0,0 @@
import path from "node:path"
import fs from "node:fs"
import axios from "axios"
import MultiqualityHLSJob from "@shared-classes/MultiqualityHLSJob"
import { standardUpload } from "@services/remoteUpload"
export default {
useContext: ["cache", "limits"],
middlewares: ["withAuthentication"],
fn: async (req, res) => {
const { url } = req.query
const userPath = path.join(this.default.contexts.cache.constructor.cachePath, req.auth.session.user_id)
const jobId = String(new Date().getTime())
const jobPath = path.resolve(userPath, "jobs", jobId)
const sourcePath = path.resolve(jobPath, `${jobId}.source`)
if (!fs.existsSync(jobPath)) {
fs.mkdirSync(jobPath, { recursive: true })
}
const sourceStream = fs.createWriteStream(sourcePath)
const response = await axios({
method: "get",
url,
responseType: "stream",
})
response.data.pipe(sourceStream)
await new Promise((resolve, reject) => {
sourceStream.on("finish", () => {
resolve()
})
sourceStream.on("error", (err) => {
reject(err)
})
})
const job = new MultiqualityHLSJob({
input: sourcePath,
outputDir: jobPath,
levels: [
{
original: true,
codec: "libx264",
bitrate: "10M",
preset: "ultrafast",
},
{
codec: "libx264",
width: 1280,
bitrate: "3M",
preset: "ultrafast",
}
]
})
await new Promise((resolve, reject) => {
job
.on("error", (err) => {
console.error(`[TRANSMUX] Transmuxing failed`, err)
reject(err)
})
.on("end", () => {
console.debug(`[TRANSMUX] Finished transmuxing > ${sourcePath}`)
resolve()
})
.run()
})
const result = await standardUpload({
isDirectory: true,
source: path.join(jobPath, "hls"),
remotePath: `${req.auth.session.user_id}/jobs/${jobId}`,
})
fs.rmSync(jobPath, { recursive: true, force: true })
return {
result
}
}
}

View File

@ -1,21 +1,12 @@
import { Duplex } from "node:stream"
import path from "node:path" import path from "node:path"
import fs from "node:fs" import fs from "node:fs"
import RemoteUpload from "@services/remoteUpload"
import { import { checkChunkUploadHeaders, handleChunkFile } from "@classes/ChunkFile"
checkChunkUploadHeaders, import Upload from "@shared-classes/Upload"
handleChunkFile, import bufferToStream from "@shared-utils/bufferToStream"
} from "@classes/ChunkFileUpload"
const availableProviders = ["b2", "standard"] const availableProviders = ["b2", "standard"]
function bufferToStream(bf) {
let tmp = new Duplex()
tmp.push(bf)
tmp.push(null)
return tmp
}
export default { export default {
useContext: ["cache", "limits"], useContext: ["cache", "limits"],
middlewares: ["withAuthentication"], middlewares: ["withAuthentication"],
@ -25,14 +16,16 @@ export default {
return return
} }
const uploadId = `${req.headers["uploader-file-id"]}_${Date.now()}` const uploadId = `${req.headers["uploader-file-id"]}`
const tmpPath = path.resolve( const workPath = path.resolve(
this.default.contexts.cache.constructor.cachePath, this.default.contexts.cache.constructor.cachePath,
req.auth.session.user_id, `${req.auth.session.user_id}-${uploadId}`,
) )
const chunksPath = path.join(workPath, "chunks")
const assembledPath = path.join(workPath, "assembled")
const limits = { const config = {
maxFileSize: maxFileSize:
parseInt(this.default.contexts.limits.maxFileSizeInMB) * parseInt(this.default.contexts.limits.maxFileSizeInMB) *
1024 * 1024 *
@ -42,93 +35,91 @@ export default {
1024 * 1024 *
1024, 1024,
useCompression: true, useCompression: true,
useProvider: "standard", useProvider: req.headers["use-provider"] ?? "standard",
} }
// const user = await req.auth.user() // const user = await req.auth.user()
// if (user.roles.includes("admin")) { // if (user.roles.includes("admin")) {
// // maxFileSize for admins 100GB // // maxFileSize for admins 100GB
// limits.maxFileSize = 100 * 1024 * 1024 * 1024 // limits.maxFileSize = 100 * 1024 * 1024 * 1024
// // optional compression for admins // // optional compression for admins
// limits.useCompression = req.headers["use-compression"] ?? false // limits.useCompression = req.headers["use-compression"] ?? false
// limits.useProvider = req.headers["provider-type"] ?? "b2" // limits.useProvider = req.headers["provider-type"] ?? "b2"
// } // }
// check if provider is valid // check if provider is valid
if (!availableProviders.includes(limits.useProvider)) { if (!availableProviders.includes(config.useProvider)) {
throw new OperationError(400, "Invalid provider") throw new OperationError(400, "Invalid provider")
} }
// create a readable stream from req.body(buffer) await fs.promises.mkdir(workPath, { recursive: true })
await fs.promises.mkdir(chunksPath, { recursive: true })
await fs.promises.mkdir(assembledPath, { recursive: true })
// create a readable stream
const dataStream = bufferToStream(await req.buffer()) const dataStream = bufferToStream(await req.buffer())
let result = await handleChunkFile(dataStream, { let assemble = await handleChunkFile(dataStream, {
tmpDir: tmpPath, chunksPath: chunksPath,
outputDir: assembledPath,
headers: req.headers, headers: req.headers,
maxFileSize: limits.maxFileSize, maxFileSize: config.maxFileSize,
maxChunkSize: limits.maxChunkSize, maxChunkSize: config.maxChunkSize,
}) })
if (typeof result === "function") { if (typeof assemble === "function") {
try { try {
result = await result() assemble = await assemble()
if (req.headers["transmux"] || limits.useCompression === true) { let transformations = req.headers["transformations"]
// add a background task
if (transformations) {
transformations = transformations
.split(",")
.map((t) => t.trim())
}
const payload = {
user_id: req.auth.session.user_id,
uploadId: uploadId,
filePath: assemble.filePath,
workPath: workPath,
transformations: transformations,
s3Provider: config.useProvider,
useCompression: config.useCompression,
}
// if has transformations, use background job
if (
(transformations && transformations.length > 0) ||
config.useCompression
) {
const job = await global.queues.createJob( const job = await global.queues.createJob(
"remote_upload", "file-process",
{ payload,
filePath: result.filePath,
parentDir: req.auth.session.user_id,
service: limits.useProvider,
useCompression: limits.useCompression,
transmux: req.headers["transmux"] ?? false,
transmuxOptions: req.headers["transmux-options"],
cachePath: tmpPath,
},
{ {
useSSE: true, useSSE: true,
}, },
) )
const sseChannelId = job.sseChannelId
return { return {
uploadId: uploadId, uploadId: payload.uploadId,
sseChannelId: sseChannelId, sseChannelId: job.sseChannelId,
eventChannelURL: `${req.headers["x-forwarded-proto"] || req.protocol}://${req.get("host")}/upload/sse_events/${sseChannelId}`, sseUrl: `${req.headers["x-forwarded-proto"] || req.protocol}://${req.get("host")}/upload/sse_events/${job.sseChannelId}`,
} }
} else {
const result = await RemoteUpload({
source: result.filePath,
parentDir: req.auth.session.user_id,
service: limits.useProvider,
useCompression: limits.useCompression,
cachePath: tmpPath,
})
return result
} }
} catch (error) {
await fs.promises
.rm(tmpPath, { recursive: true, force: true })
.catch(() => {
return false
})
throw new OperationError( return await Upload.fileHandle(payload)
error.code ?? 500, } catch (error) {
error.message ?? "Failed to upload file", await fs.promises.rm(workPath, { recursive: true })
) throw error
} }
} }
return { return {
ok: 1, next: true,
chunkNumber: req.headers["uploader-chunk-number"], chunkNumber: req.headers["uploader-chunk-number"],
config: config,
} }
}, },
} }

View File

@ -1,48 +1,50 @@
import path from "node:path" import path from "node:path"
import fs from "node:fs" import fs from "node:fs"
import RemoteUpload from "@services/remoteUpload" import Upload from "@shared-classes/Upload"
export default { export default {
useContext: ["cache"], useContext: ["cache"],
middlewares: [ middlewares: ["withAuthentication"],
"withAuthentication", fn: async (req, res) => {
], const workPath = path.resolve(
fn: async (req, res) => { this.default.contexts.cache.constructor.cachePath,
const { cache } = this.default.contexts `${req.auth.session.user_id}-${nanoid()}`,
)
const providerType = req.headers["provider-type"] ?? "standard" await fs.promises.mkdir(workPath, { recursive: true })
const userPath = path.join(cache.constructor.cachePath, req.auth.session.user_id) let localFilepath = null
let localFilepath = null await req.multipart(async (field) => {
let tmpPath = path.resolve(userPath, `${Date.now()}`) if (!field.file) {
throw new OperationError(400, "Missing file")
}
await req.multipart(async (field) => { localFilepath = path.join(workPath, "file")
if (!field.file) {
throw new OperationError(400, "Missing file")
}
localFilepath = path.join(tmpPath, field.file.name) await field.write(localFilepath)
})
const existTmpDir = await fs.promises.stat(tmpPath).then(() => true).catch(() => false) let transformations = req.headers["transformations"]
if (!existTmpDir) { if (transformations) {
await fs.promises.mkdir(tmpPath, { recursive: true }) transformations = transformations.split(",").map((t) => t.trim())
} }
await field.write(localFilepath) const result = await Upload.fileHandle({
}) user_id: req.auth.session.user_id,
filePath: localFilepath,
workPath: workPath,
transformations: transformations,
})
const result = await RemoteUpload({ res.header("deprecated", "true")
parentDir: req.auth.session.user_id, res.header(
source: localFilepath, "deprecation-replacement",
service: providerType, "Use the new chunked upload API endpoint",
useCompression: ToBoolean(req.headers["use-compression"]) ?? true, )
})
fs.promises.rm(tmpPath, { recursive: true, force: true }) return result
},
return result
}
} }

View File

@ -1,30 +0,0 @@
const ffmpeg = require("fluent-ffmpeg")
export default async (file) => {
// analize metadata
let metadata = await new Promise((resolve, reject) => {
ffmpeg.ffprobe(file.filepath, (err, data) => {
if (err) {
return reject(err)
}
resolve(data)
})
}).catch((err) => {
console.error(err)
return {}
})
if (metadata.format) {
metadata = metadata.format
}
file.metadata = {
duration: metadata.duration,
bitrate: metadata.bit_rate,
size: metadata.size,
}
return file
}

View File

@ -1,59 +0,0 @@
import fs from "node:fs"
import path from "node:path"
import Sharp from "sharp"
const imageProcessingConf = {
// TODO: Get image sizeThreshold from DB
sizeThreshold: 10 * 1024 * 1024,
// TODO: Get image quality from DB
imageQuality: 80,
}
const imageTypeToConfig = {
png: {
compressionLevel: Math.floor(imageProcessingConf.imageQuality / 100),
},
default: {
quality: imageProcessingConf.imageQuality
}
}
/**
* Processes an image file and transforms it if it's above a certain size threshold.
*
* @async
* @function
* @param {Object} file - The file to be processed.
* @param {string} file.filepath - The path of the file to be processed.
* @param {string} file.hash - The hash of the file to be processed.
* @param {string} file.cachePath - The cache path of the file to be processed.
* @throws {Error} If the file parameter is not provided.
* @return {Object} The processed file object.
*/
async function processImage(file) {
if (!file) {
throw new Error("file is required")
}
const stat = await fs.promises.stat(file.filepath)
if (stat.size < imageProcessingConf.sizeThreshold) {
return file
}
let image = await Sharp(file.filepath)
const { format } = await image.metadata()
image = await image[format](imageTypeToConfig[format] ?? imageTypeToConfig.default).withMetadata()
const outputFilepath = path.resolve(file.cachePath, `${file.hash}_transformed.${format}`)
await image.toFile(outputFilepath)
file.filepath = outputFilepath
return file
}
export default processImage

View File

@ -1,53 +0,0 @@
import fs from "node:fs"
import mimetypes from "mime-types"
import processVideo from "./video"
import processImage from "./image"
import processAudio from "./audio"
const fileTransformer = {
// video
"video/avi": processVideo,
"video/quicktime": processVideo,
"video/mp4": processVideo,
"video/webm": processVideo,
//image
"image/jpeg": processImage,
"image/png": processImage,
"image/gif": processImage,
"image/bmp": processImage,
"image/tiff": processImage,
"image/webp": processImage,
"image/jfif": processImage,
// audio
"audio/flac": processAudio,
"audio/x-flac": processAudio,
"audio/mp3": processAudio,
"audio/x-mp3": processAudio,
"audio/mpeg": processAudio,
"audio/x-mpeg": processAudio,
"audio/ogg": processAudio,
"audio/x-ogg": processAudio,
"audio/wav": processAudio,
"audio/x-wav": processAudio,
}
export default async (file) => {
if (!file) {
throw new Error("file is required")
}
if (!fs.existsSync(file.filepath)) {
throw new Error(`File ${file.filepath} not found`)
}
const fileMimetype = mimetypes.lookup(file.filepath)
if (typeof fileTransformer[fileMimetype] !== "function") {
console.debug(`File (${file.filepath}) has mimetype ${fileMimetype} and will not be processed`)
return file
}
return await fileTransformer[fileMimetype](file)
}

View File

@ -1,43 +0,0 @@
import videoTranscode from "@services/videoTranscode"
/**
* Processes a video file based on the specified options.
*
* @async
* @param {Object} file - The video file to process.
* @param {Object} [options={}] - The options object to use for processing.
* @param {string} [options.videoCodec="libx264"] - The video codec to use.
* @param {string} [options.format="mp4"] - The format to use.
* @param {number} [options.audioBitrate=128] - The audio bitrate to use.
* @param {number} [options.videoBitrate=2024] - The video bitrate to use.
* @throws {Error} Throws an error if file parameter is not provided.
* @return {Object} The processed video file object.
*/
async function processVideo(file, options = {}) {
if (!file) {
throw new Error("file is required")
}
// TODO: Get values from db
const {
videoCodec = "libx264",
format = "mp4",
audioBitrate = 128,
videoBitrate = 3000,
} = options
const result = await videoTranscode(file.filepath, {
videoCodec,
format,
audioBitrate,
videoBitrate: [videoBitrate, true],
extraOptions: ["-threads 2"],
})
file.filepath = result.filepath
file.filename = result.filename
return file
}
export default processVideo

View File

@ -1,162 +0,0 @@
import fs from "node:fs"
import path from "node:path"
import mimeTypes from "mime-types"
import getFileHash from "@shared-utils/readFileHash"
import PostProcess from "../post-process"
import Transmux from "../transmux"
import StandardUpload from "./providers/standard"
import B2Upload from "./providers/b2"
export default async ({
source,
parentDir,
service,
useCompression,
cachePath,
transmux,
transmuxOptions,
isDirectory,
onProgress,
}) => {
if (!source) {
throw new OperationError(500, "source is required")
}
if (!service) {
service = "standard"
}
if (!parentDir) {
parentDir = "/"
}
if (transmuxOptions) {
transmuxOptions = JSON.parse(transmuxOptions)
}
if (useCompression) {
if (typeof onProgress === "function") {
onProgress(10, {
event: "post_processing",
})
}
try {
const processOutput = await PostProcess({
filepath: source,
cachePath: cachePath,
})
if (processOutput) {
if (processOutput.filepath) {
source = processOutput.filepath
}
}
} catch (error) {
console.error(error)
throw new OperationError(500, `Failed to process file`)
}
}
if (transmux) {
if (typeof onProgress === "function") {
onProgress(30, {
event: "transmuxing",
})
}
try {
const processOutput = await Transmux({
transmuxer: transmux,
transmuxOptions: transmuxOptions,
filepath: source,
cachePath: cachePath,
})
if (processOutput) {
if (processOutput.filepath) {
source = processOutput.filepath
}
if (processOutput.isDirectory) {
isDirectory = true
}
}
} catch (error) {
console.error(error)
throw new OperationError(500, `Failed to transmux file`)
}
}
const type = mimeTypes.lookup(path.basename(source))
const hash = await getFileHash(fs.createReadStream(source))
let fileId = `${hash}`
// FIXME: This is a walkaround to avoid to hashing the entire directories
if (isDirectory) {
fileId = global.nanoid()
}
let remotePath = path.join(parentDir, fileId)
let result = {}
const metadata = {
"Content-Type": type,
"File-Hash": hash,
}
if (typeof onProgress === "function") {
onProgress(80, {
event: "uploading_s3",
service: service,
})
}
try {
switch (service) {
case "b2":
if (!global.b2Storage) {
throw new OperationError(
500,
"B2 storage not configured on environment, unsupported service. Please use `standard` service.",
)
}
result = await B2Upload({
source: isDirectory ? path.dirname(source) : source,
remotePath: remotePath,
metadata: metadata,
isDirectory: isDirectory,
targetFilename: isDirectory ? path.basename(source) : null,
})
break
case "standard":
result = await StandardUpload({
source: isDirectory ? path.dirname(source) : source,
remotePath: remotePath,
metadata: metadata,
isDirectory: isDirectory,
targetFilename: isDirectory ? path.basename(source) : null,
})
break
default:
throw new OperationError(500, "Unsupported service")
}
} catch (error) {
console.error(error)
throw new OperationError(500, "Failed to upload to storage")
}
if (typeof onProgress === "function") {
onProgress(100, {
event: "done",
result: result,
})
}
return result
}

View File

@ -1,90 +0,0 @@
import fs from "node:fs"
import path from "node:path"
import pMap from "p-map"
export default async function b2Upload({
source,
remotePath,
metadata = {},
targetFilename,
isDirectory,
retryNumber = 0
}) {
if (isDirectory) {
let files = await fs.promises.readdir(source)
files = files.map((file) => {
const filePath = path.join(source, file)
const isTargetDirectory = fs.lstatSync(filePath).isDirectory()
return {
source: filePath,
remotePath: path.join(remotePath, file),
isDirectory: isTargetDirectory,
}
})
await pMap(
files,
b2Upload,
{
concurrency: 5
}
)
return {
id: remotePath,
url: `https://${process.env.B2_CDN_ENDPOINT}/${process.env.B2_BUCKET}/${remotePath}/${targetFilename}`,
metadata: metadata,
}
}
try {
await global.b2Storage.authorize()
if (!fs.existsSync(source)) {
throw new OperationError(500, "File not found")
}
const uploadUrl = await global.b2Storage.getUploadUrl({
bucketId: process.env.B2_BUCKET_ID,
})
console.debug(`Uploading object to B2 Storage >`, {
source: source,
remote: remotePath,
})
const data = await fs.promises.readFile(source)
await global.b2Storage.uploadFile({
uploadUrl: uploadUrl.data.uploadUrl,
uploadAuthToken: uploadUrl.data.authorizationToken,
fileName: remotePath,
data: data,
info: metadata
})
} catch (error) {
console.error(error)
if (retryNumber < 5) {
return await b2Upload({
source,
remotePath,
metadata,
targetFilename,
isDirectory,
retryNumber: retryNumber + 1
})
}
throw new OperationError(500, "B2 upload failed")
}
return {
id: remotePath,
url: `https://${process.env.B2_CDN_ENDPOINT}/${process.env.B2_BUCKET}/${remotePath}`,
metadata: metadata,
}
}

View File

@ -1,58 +0,0 @@
import fs from "node:fs"
import path from "node:path"
import pMap from "p-map"
export default async function standardUpload({
source,
remotePath,
metadata = {},
targetFilename,
isDirectory,
}) {
if (isDirectory) {
let files = await fs.promises.readdir(source)
files = files.map((file) => {
const filePath = path.join(source, file)
const isTargetDirectory = fs.lstatSync(filePath).isDirectory()
return {
source: filePath,
remotePath: path.join(remotePath, file),
isDirectory: isTargetDirectory,
}
})
await pMap(
files,
standardUpload,
{
concurrency: 3
}
)
return {
id: remotePath,
url: global.storage.composeRemoteURL(remotePath, targetFilename),
metadata: metadata,
}
}
console.debug(`Uploading object to S3 Minio >`, {
source: source,
remote: remotePath,
})
// upload to storage
await global.storage.fPutObject(process.env.S3_BUCKET, remotePath, source, metadata)
// compose url
const url = global.storage.composeRemoteURL(remotePath)
return {
id: remotePath,
url: url,
metadata: metadata,
}
}

View File

@ -1,108 +0,0 @@
import fs from "node:fs"
import path from "node:path"
import MultiqualityHLSJob from "@shared-classes/MultiqualityHLSJob"
import SegmentedAudioMPDJob from "@shared-classes/SegmentedAudioMPDJob"
const transmuxers = [
{
id: "mq-hls",
container: "hls",
extension: "m3u8",
multipleOutput: true,
buildCommand: (input, outputDir) => {
return new MultiqualityHLSJob({
input: input,
outputDir: outputDir,
outputMasterName: "master.m3u8",
levels: [
{
original: true,
codec: "libx264",
bitrate: "10M",
preset: "ultrafast",
},
{
codec: "libx264",
width: 1280,
bitrate: "3M",
preset: "ultrafast",
}
]
})
},
},
{
id: "a-dash",
container: "dash",
extension: "mpd",
multipleOutput: true,
buildCommand: (input, outputDir) => {
return new SegmentedAudioMPDJob({
input: input,
outputDir: outputDir,
outputMasterName: "master.mpd",
audioCodec: "flac",
//audioBitrate: "1600k",
//audioSampleRate: 96000,
segmentTime: 10,
})
}
},
]
export default async (params) => {
if (!params) {
throw new Error("params is required")
}
if (!params.filepath) {
throw new Error("filepath is required")
}
if (!params.cachePath) {
throw new Error("cachePath is required")
}
if (!params.transmuxer) {
throw new Error("transmuxer is required")
}
if (!fs.existsSync(params.filepath)) {
throw new Error(`File ${params.filepath} not found`)
}
const transmuxer = transmuxers.find((item) => item.id === params.transmuxer)
if (!transmuxer) {
throw new Error(`Transmuxer ${params.transmuxer} not found`)
}
const jobPath = path.dirname(params.filepath)
if (!fs.existsSync(path.dirname(jobPath))) {
fs.mkdirSync(path.dirname(jobPath), { recursive: true })
}
return await new Promise((resolve, reject) => {
try {
const command = transmuxer.buildCommand(params.filepath, jobPath)
command
.on("progress", function (progress) {
console.log("Processing: " + progress.percent + "% done")
})
.on("error", (err) => {
reject(err)
})
.on("end", (data) => {
resolve(data)
})
.run()
} catch (error) {
console.error(`[TRANSMUX] Transmuxing failed`, error)
reject(error)
}
})
}

View File

@ -1,98 +0,0 @@
import path from "path"
const ffmpeg = require("fluent-ffmpeg")
const defaultParams = {
audioBitrate: 128,
videoBitrate: 1024,
videoCodec: "libvpx",
audioCodec: "libvorbis",
format: "mp4",
}
const maxTasks = 5
export default (input, params = defaultParams) => {
return new Promise((resolve, reject) => {
if (!global.ffmpegTasks) {
global.ffmpegTasks = []
}
if (global.ffmpegTasks.length >= maxTasks) {
return reject(new Error("Too many transcoding tasks"))
}
const outputFilename = `${path.basename(input).split(".")[0]}_ff.${params.format ?? "webm"}`
const outputFilepath = `${path.dirname(input)}/${outputFilename}`
console.debug(`[TRANSCODING] Transcoding ${input} to ${outputFilepath}`)
const onEnd = async () => {
console.debug(
`[TRANSCODING] Finished transcode ${input} to ${outputFilepath}`,
)
return resolve({
filename: outputFilename,
filepath: outputFilepath,
})
}
const onError = (err) => {
console.error(
`[TRANSCODING] Transcoding ${input} to ${outputFilepath} failed`,
err,
)
return reject(err)
}
let exec = null
const commands = {
input: input,
...params,
output: outputFilepath,
outputOptions: ["-preset veryfast"],
}
// chain methods
for (let key in commands) {
if (exec === null) {
exec = ffmpeg(commands[key])
continue
}
if (key === "extraOptions" && Array.isArray(commands[key])) {
for (const option of commands[key]) {
exec = exec.inputOptions(option)
}
continue
}
if (key === "outputOptions" && Array.isArray(commands[key])) {
for (const option of commands[key]) {
exec = exec.outputOptions(option)
}
continue
}
if (typeof exec[key] !== "function") {
console.warn(`[TRANSCODING] Method ${key} is not a function`)
return false
}
if (Array.isArray(commands[key])) {
exec = exec[key](...commands[key])
} else {
exec = exec[key](commands[key])
}
continue
}
exec.on("error", onError).on("end", onEnd).run()
})
}

View File

@ -1,20 +0,0 @@
import fs from "node:fs"
import os from "node:os"
import axios from "axios"
export default async (outputDir) => {
const arch = os.arch()
console.log(`Downloading ffmpeg for ${arch}...`)
const baseURL = `https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-${arch}-static.tar.xz`
const response = await axios.get(baseURL, {
responseType: "stream"
})
const ffmpegPath = path.join(outputDir, `ffmpeg-${arch}.tar.xz`)
const ffmpegFile = fs.createWriteStream(ffmpegPath)
response.data.pipe(ffmpegFile)
}

View File

@ -1,20 +0,0 @@
export default (from, to) => {
const resolvedUrl = new URL(to, new URL(from, "resolve://"))
if (resolvedUrl.protocol === "resolve:") {
let { pathname, search, hash } = resolvedUrl
if (to.includes("@")) {
const fromUrl = new URL(from)
const toUrl = new URL(to, fromUrl.origin)
pathname = toUrl.pathname
search = toUrl.search
hash = toUrl.hash
}
return pathname + search + hash
}
return resolvedUrl.toString()
}

View File

@ -1,23 +0,0 @@
import fs from "fs"
import path from "path"
async function syncFolder(dir, destPath) {
const files = await fs.promises.readdir(dir)
for await (const file of files) {
const filePath = path.resolve(dir, file)
const desitinationFilePath = `${destPath}/${file}`
const stat = fs.statSync(filePath)
if (stat.isDirectory()) {
await syncFolder(filePath, desitinationFilePath)
} else {
const fileContent = await fs.promises.readFile(filePath)
await global.storage.putObject(process.env.S3_BUCKET, desitinationFilePath, fileContent)
}
}
}
export default syncFolder

View File

@ -7,9 +7,10 @@ import SharedMiddlewares from "@shared-middlewares"
export default class API extends Server { export default class API extends Server {
static refName = "main" static refName = "main"
static enableWebsockets = true static useEngine = "hyper-express-ng"
static routesPath = `${__dirname}/routes` static routesPath = `${__dirname}/routes`
static listen_port = process.env.HTTP_LISTEN_PORT || 3000 static listen_port = process.env.HTTP_LISTEN_PORT || 3000
static enableWebsockets = false
middlewares = { middlewares = {
...require("@middlewares").default, ...require("@middlewares").default,
@ -26,8 +27,6 @@ export default class API extends Server {
await this.contexts.db.initialize() await this.contexts.db.initialize()
await StartupDB() await StartupDB()
} }
handleWsAuth = require("@shared-lib/handleWsAuth").default
} }
Boot(API) Boot(API)

View File

@ -1,7 +1,6 @@
{ {
"name": "main", "name": "main",
"version": "0.60.2", "dependencies": {
"dependencies": { "@octokit/rest": "^20.0.2"
"@octokit/rest": "^20.0.2" }
}
} }

View File

@ -1,24 +1,27 @@
import { Extension } from "@db_models" import { Extension } from "@db_models"
export default async function resolve(payload) { export default async function resolve(payload) {
let { user_id, pkg } = payload let { user_id, pkg } = payload
const [pkgName, pkgVersion] = pkg.split("@") let [pkgName, pkgVersion] = pkg.split("@")
if (!pkgVersion) { if (!pkgVersion) {
pkgVersion = "latest" pkgVersion = "latest"
} }
if (pkgVersion === "latest") { if (pkgVersion === "latest") {
return await Extension.findOne({ return await Extension.findOne({
user_id, user_id,
name: pkgName, name: pkgName,
}).sort({ version: -1 }).limit(1).exec() })
} .sort({ version: -1 })
.limit(1)
.exec()
}
return await Extension.findOne({ return await Extension.findOne({
user_id, user_id,
name: pkgName, name: pkgName,
version: pkgVersion, version: pkgVersion,
}) })
} }

View File

@ -1,41 +1,47 @@
import { Server } from "linebridge" import { Server } from "linebridge"
import B2 from "backblaze-b2"
import DbManager from "@shared-classes/DbManager" import DbManager from "@shared-classes/DbManager"
import CacheService from "@shared-classes/CacheService" import CacheService from "@shared-classes/CacheService"
import StorageClient from "@shared-classes/StorageClient"
import SharedMiddlewares from "@shared-middlewares" import SharedMiddlewares from "@shared-middlewares"
class API extends Server { class API extends Server {
static refName = "marketplace" static refName = "marketplace"
static wsRoutesPath = `${__dirname}/ws_routes` static useEngine = "hyper-express-ng"
static routesPath = `${__dirname}/routes` static routesPath = `${__dirname}/routes`
static listen_port = process.env.HTTP_LISTEN_PORT ?? 3005 static listen_port = process.env.HTTP_LISTEN_PORT ?? 3005
middlewares = { middlewares = {
...SharedMiddlewares ...SharedMiddlewares,
} }
contexts = { contexts = {
db: new DbManager(), db: new DbManager(),
b2: new B2({ cache: new CacheService({
applicationKeyId: process.env.B2_KEY_ID, fsram: false,
applicationKey: process.env.B2_APP_KEY, }),
}), storage: StorageClient({
cache: new CacheService({ endPoint: process.env.B2_ENDPOINT,
fsram: false cdnUrl: process.env.B2_CDN_ENDPOINT,
}), defaultBucket: process.env.B2_BUCKET,
} accessKey: process.env.B2_KEY_ID,
secretKey: process.env.B2_APP_KEY,
port: 443,
useSSL: true,
setupBucket: false,
}),
}
async onInitialize() { async onInitialize() {
await this.contexts.db.initialize() await this.contexts.db.initialize()
await this.contexts.b2.authorize() await this.contexts.storage.initialize()
global.cache = this.contexts.cache global.cache = this.contexts.cache
global.b2 = this.contexts.b2 global.storages = {
} standard: this.contexts.storage,
}
handleWsAuth = require("@shared-lib/handleWsAuth").default }
} }
Boot(API) Boot(API)

View File

@ -1,9 +1,6 @@
{ {
"name": "marketplace", "name": "marketplace",
"dependencies": { "dependencies": {
"7zip-min": "^1.4.4", "7zip-min": "^1.4.4"
"backblaze-b2": "^1.7.0",
"sucrase": "^3.32.0",
"uglify-js": "^3.17.4"
} }
} }

Some files were not shown because too many files have changed in this diff Show More