mirror of
https://github.com/ragestudio/comty.git
synced 2025-06-09 02:24:16 +00:00
commit
88a4e063a6
@ -1 +1 @@
|
||||
Subproject commit ff38d45b9686ccbd2e902477bde4cd7eb7d251e8
|
||||
Subproject commit 57d8b4bed14b0b35d1d9753847ac39710e0d9be5
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comty/app",
|
||||
"version": "1.37.1@alpha",
|
||||
"version": "1.38.0@alpha",
|
||||
"license": "ComtyLicense",
|
||||
"main": "electron/main",
|
||||
"type": "module",
|
||||
@ -34,7 +34,7 @@
|
||||
"bear-react-carousel": "^4.0.10-alpha.0",
|
||||
"classnames": "2.3.1",
|
||||
"comty.js": "^0.63.1",
|
||||
"dashjs": "^4.7.4",
|
||||
"dashjs": "^5.0.0",
|
||||
"dompurify": "^3.0.0",
|
||||
"fast-average-color": "^9.2.0",
|
||||
"fuse.js": "6.5.3",
|
||||
@ -49,6 +49,7 @@
|
||||
"moment": "2.29.4",
|
||||
"motion": "^12.4.2",
|
||||
"mpegts.js": "^1.6.10",
|
||||
"music-metadata": "^11.2.1",
|
||||
"plyr": "^3.7.8",
|
||||
"prop-types": "^15.8.1",
|
||||
"qs": "^6.14.0",
|
||||
|
@ -170,7 +170,7 @@ export default class ChunkedUpload {
|
||||
|
||||
// check if is the last chunk, if so, handle sse events
|
||||
if (this.chunkCount === this.totalChunks) {
|
||||
if (data.sseChannelId || data.eventChannelURL) {
|
||||
if (data.sseChannelId || data.sseUrl) {
|
||||
this.waitOnSSE(data)
|
||||
} else {
|
||||
this.events.emit("finish", data)
|
||||
@ -178,9 +178,8 @@ export default class ChunkedUpload {
|
||||
}
|
||||
|
||||
this.events.emit("progress", {
|
||||
percentProgress: Math.round(
|
||||
(100 / this.totalChunks) * this.chunkCount,
|
||||
),
|
||||
percent: Math.round((100 / this.totalChunks) * this.chunkCount),
|
||||
state: "Uploading",
|
||||
})
|
||||
} catch (error) {
|
||||
this.events.emit("error", error)
|
||||
@ -196,12 +195,9 @@ export default class ChunkedUpload {
|
||||
}
|
||||
|
||||
waitOnSSE(data) {
|
||||
console.log(
|
||||
`[UPLOADER] Connecting to SSE channel >`,
|
||||
data.eventChannelURL,
|
||||
)
|
||||
console.log(`[UPLOADER] Connecting to SSE channel >`, data.sseUrl)
|
||||
|
||||
const eventSource = new EventSource(data.eventChannelURL)
|
||||
const eventSource = new EventSource(data.sseUrl)
|
||||
|
||||
eventSource.onerror = (error) => {
|
||||
this.events.emit("error", error)
|
||||
@ -218,19 +214,20 @@ export default class ChunkedUpload {
|
||||
|
||||
console.log(`[UPLOADER] SSE Event >`, messageData)
|
||||
|
||||
if (messageData.status === "done") {
|
||||
if (messageData.event === "done") {
|
||||
this.events.emit("finish", messageData.result)
|
||||
eventSource.close()
|
||||
}
|
||||
|
||||
if (messageData.status === "error") {
|
||||
if (messageData.event === "error") {
|
||||
this.events.emit("error", messageData.result)
|
||||
eventSource.close()
|
||||
}
|
||||
|
||||
if (messageData.status === "progress") {
|
||||
if (messageData.state) {
|
||||
this.events.emit("progress", {
|
||||
percentProgress: messageData.progress,
|
||||
percent: messageData.percent,
|
||||
state: messageData.state,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -7,55 +7,62 @@ import UploadButton from "@components/UploadButton"
|
||||
import "./index.less"
|
||||
|
||||
const CoverEditor = (props) => {
|
||||
const { value, onChange, defaultUrl } = props
|
||||
const { value, onChange, defaultUrl } = props
|
||||
|
||||
const [init, setInit] = React.useState(true)
|
||||
const [url, setUrl] = React.useState(value)
|
||||
const [init, setInit] = React.useState(true)
|
||||
const [url, setUrl] = React.useState(value)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!init) {
|
||||
onChange(url)
|
||||
}
|
||||
}, [url])
|
||||
React.useEffect(() => {
|
||||
if (!init) {
|
||||
onChange(url)
|
||||
}
|
||||
}, [url])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!value) {
|
||||
setUrl(defaultUrl)
|
||||
} else {
|
||||
setUrl(value)
|
||||
}
|
||||
React.useEffect(() => {
|
||||
if (!value) {
|
||||
setUrl(defaultUrl)
|
||||
} else {
|
||||
setUrl(value)
|
||||
}
|
||||
|
||||
setInit(false)
|
||||
}, [])
|
||||
setInit(false)
|
||||
}, [])
|
||||
|
||||
return <div className="cover-editor">
|
||||
<div className="cover-editor-preview">
|
||||
<Image
|
||||
src={url}
|
||||
/>
|
||||
</div>
|
||||
// Handle when value prop change
|
||||
React.useEffect(() => {
|
||||
if (!value) {
|
||||
setUrl(defaultUrl)
|
||||
} else {
|
||||
setUrl(value)
|
||||
}
|
||||
}, [value])
|
||||
|
||||
<div className="cover-editor-actions">
|
||||
<UploadButton
|
||||
onSuccess={(uid, response) => {
|
||||
setUrl(response.url)
|
||||
}}
|
||||
/>
|
||||
return (
|
||||
<div className="cover-editor">
|
||||
<div className="cover-editor-preview">
|
||||
<Image src={url} />
|
||||
</div>
|
||||
|
||||
<antd.Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
setUrl(defaultUrl)
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</antd.Button>
|
||||
<div className="cover-editor-actions">
|
||||
<UploadButton
|
||||
onSuccess={(uid, response) => {
|
||||
setUrl(response.url)
|
||||
}}
|
||||
/>
|
||||
|
||||
{
|
||||
props.extraActions
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<antd.Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
setUrl(defaultUrl)
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</antd.Button>
|
||||
|
||||
{props.extraActions}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CoverEditor
|
||||
|
@ -392,9 +392,7 @@ const PlaylistView = (props) => {
|
||||
key={item._id}
|
||||
order={item._id}
|
||||
track={item}
|
||||
onClickPlayBtn={() =>
|
||||
handleOnClickTrack(item)
|
||||
}
|
||||
onPlay={() => handleOnClickTrack(item)}
|
||||
changeState={(update) =>
|
||||
handleTrackChangeState(
|
||||
item._id,
|
||||
@ -418,7 +416,7 @@ const PlaylistView = (props) => {
|
||||
<MusicTrack
|
||||
order={index + 1}
|
||||
track={item}
|
||||
onClickPlayBtn={() =>
|
||||
onPlay={() =>
|
||||
handleOnClickTrack(item)
|
||||
}
|
||||
changeState={(update) =>
|
||||
|
@ -52,6 +52,10 @@ const Track = (props) => {
|
||||
const isPlaying = isCurrent && playback_status === "playing"
|
||||
|
||||
const handleClickPlayBtn = React.useCallback(() => {
|
||||
if (typeof props.onPlay === "function") {
|
||||
return props.onPlay(props.track)
|
||||
}
|
||||
|
||||
if (typeof props.onClickPlayBtn === "function") {
|
||||
props.onClickPlayBtn(props.track)
|
||||
}
|
||||
|
@ -12,95 +12,96 @@ import "./index.less"
|
||||
dayjs.extend(customParseFormat)
|
||||
|
||||
const VideoEditor = (props) => {
|
||||
function handleChange(key, value) {
|
||||
if (typeof props.onChange !== "function") {
|
||||
return false
|
||||
}
|
||||
function handleChange(key, value) {
|
||||
if (typeof props.onChange !== "function") {
|
||||
return false
|
||||
}
|
||||
|
||||
props.onChange(key, value)
|
||||
}
|
||||
props.onChange(key, value)
|
||||
}
|
||||
|
||||
return <div className="video-editor">
|
||||
<h1>
|
||||
<Icons.MdVideocam />
|
||||
Video
|
||||
</h1>
|
||||
return (
|
||||
<div className="video-editor">
|
||||
<h1>
|
||||
<Icons.MdVideocam />
|
||||
Video
|
||||
</h1>
|
||||
|
||||
{
|
||||
(!props.videoSourceURL) && <antd.Empty
|
||||
image={<Icons.MdVideocam />}
|
||||
description="No video"
|
||||
/>
|
||||
}
|
||||
{!props.videoSourceURL && (
|
||||
<antd.Empty
|
||||
image={<Icons.MdVideocam />}
|
||||
description="No video"
|
||||
/>
|
||||
)}
|
||||
|
||||
{
|
||||
props.videoSourceURL && <div className="video-editor-preview">
|
||||
<VideoPlayer
|
||||
controls={[
|
||||
"play",
|
||||
"current-time",
|
||||
"seek-time",
|
||||
"duration",
|
||||
"progress",
|
||||
"settings",
|
||||
]}
|
||||
src={props.videoSourceURL}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
{props.videoSourceURL && (
|
||||
<div className="video-editor-preview">
|
||||
<VideoPlayer
|
||||
controls={[
|
||||
"play",
|
||||
"current-time",
|
||||
"seek-time",
|
||||
"duration",
|
||||
"progress",
|
||||
"settings",
|
||||
]}
|
||||
src={props.videoSourceURL}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-column align-start gap10">
|
||||
<div className="flex-row align-center gap10">
|
||||
<span>
|
||||
<Icons.MdAccessTime />
|
||||
Start video sync at
|
||||
</span>
|
||||
<div className="flex-column align-start gap10">
|
||||
<div className="flex-row align-center gap10">
|
||||
<span>
|
||||
<Icons.MdAccessTime />
|
||||
Start video sync at
|
||||
</span>
|
||||
|
||||
<code>{props.startSyncAt ?? "not set"}</code>
|
||||
</div>
|
||||
<code>{props.startSyncAt ?? "not set"}</code>
|
||||
</div>
|
||||
|
||||
<div className="flex-row align-center gap10">
|
||||
<span>Set to:</span>
|
||||
<div className="flex-row align-center gap10">
|
||||
<span>Set to:</span>
|
||||
|
||||
<antd.TimePicker
|
||||
showNow={false}
|
||||
defaultValue={props.startSyncAt && dayjs((props.startSyncAt), "mm:ss:SSS")}
|
||||
format={"mm:ss:SSS"}
|
||||
onChange={(time, str) => {
|
||||
handleChange("startSyncAt", str)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<antd.TimePicker
|
||||
showNow={false}
|
||||
defaultValue={
|
||||
props.startSyncAt &&
|
||||
dayjs(props.startSyncAt, "mm:ss:SSS")
|
||||
}
|
||||
format={"mm:ss:SSS"}
|
||||
onChange={(time, str) => {
|
||||
handleChange("startSyncAt", str)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="video-editor-actions">
|
||||
<UploadButton
|
||||
onSuccess={(id, response) => {
|
||||
handleChange("videoSourceURL", response.url)
|
||||
}}
|
||||
accept={[
|
||||
"video/*",
|
||||
]}
|
||||
headers={{
|
||||
"transmux": "mq-hls",
|
||||
}}
|
||||
disabled={props.loading}
|
||||
>
|
||||
Upload video
|
||||
</UploadButton>
|
||||
|
||||
or
|
||||
|
||||
<antd.Input
|
||||
placeholder="Set a video HLS URL"
|
||||
onChange={(e) => {
|
||||
handleChange("videoSourceURL", e.target.value)
|
||||
}}
|
||||
value={props.videoSourceURL}
|
||||
disabled={props.loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="video-editor-actions">
|
||||
<UploadButton
|
||||
onSuccess={(id, response) => {
|
||||
handleChange("videoSourceURL", response.url)
|
||||
}}
|
||||
accept={["video/*"]}
|
||||
headers={{
|
||||
transformations: "mq-hls",
|
||||
}}
|
||||
disabled={props.loading}
|
||||
>
|
||||
Upload video
|
||||
</UploadButton>
|
||||
or
|
||||
<antd.Input
|
||||
placeholder="Set a video HLS URL"
|
||||
onChange={(e) => {
|
||||
handleChange("videoSourceURL", e.target.value)
|
||||
}}
|
||||
value={props.videoSourceURL}
|
||||
disabled={props.loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default VideoEditor
|
||||
export default VideoEditor
|
||||
|
@ -1,280 +1,332 @@
|
||||
import React from "react"
|
||||
import * as antd from "antd"
|
||||
|
||||
import { Icons, createIconRender } from "@components/Icons"
|
||||
|
||||
import MusicModel from "@models/music"
|
||||
|
||||
import compareObjectsByProperties from "@utils/compareObjectsByProperties"
|
||||
import useUrlQueryActiveKey from "@hooks/useUrlQueryActiveKey"
|
||||
|
||||
import TrackManifest from "@cores/player/classes/TrackManifest"
|
||||
|
||||
import { DefaultReleaseEditorState, ReleaseEditorStateContext } from "@contexts/MusicReleaseEditor"
|
||||
import {
|
||||
DefaultReleaseEditorState,
|
||||
ReleaseEditorStateContext,
|
||||
} from "@contexts/MusicReleaseEditor"
|
||||
|
||||
import Tabs from "./tabs"
|
||||
|
||||
import "./index.less"
|
||||
|
||||
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 [loading, setLoading] = React.useState(true)
|
||||
const [submitError, setSubmitError] = React.useState(null)
|
||||
const [submitting, setSubmitting] = React.useState(false)
|
||||
const [loading, setLoading] = React.useState(true)
|
||||
const [submitError, setSubmitError] = React.useState(null)
|
||||
|
||||
const [loadError, setLoadError] = React.useState(null)
|
||||
const [globalState, setGlobalState] = React.useState(DefaultReleaseEditorState)
|
||||
const [loadError, setLoadError] = React.useState(null)
|
||||
const [globalState, setGlobalState] = React.useState(
|
||||
DefaultReleaseEditorState,
|
||||
)
|
||||
const [initialValues, setInitialValues] = React.useState({})
|
||||
|
||||
const [customPage, setCustomPage] = React.useState(null)
|
||||
const [customPageActions, setCustomPageActions] = React.useState([])
|
||||
const [customPage, setCustomPage] = React.useState(null)
|
||||
const [customPageActions, setCustomPageActions] = React.useState([])
|
||||
|
||||
const [selectedTab, setSelectedTab] = useUrlQueryActiveKey({
|
||||
defaultKey: "info",
|
||||
queryKey: "tab"
|
||||
})
|
||||
const [selectedTab, setSelectedTab] = useUrlQueryActiveKey({
|
||||
defaultKey: "info",
|
||||
queryKey: "tab",
|
||||
})
|
||||
|
||||
async function initialize() {
|
||||
setLoading(true)
|
||||
setLoadError(null)
|
||||
async function initialize() {
|
||||
setLoading(true)
|
||||
setLoadError(null)
|
||||
|
||||
if (release_id !== "new") {
|
||||
try {
|
||||
let releaseData = await MusicModel.getReleaseData(release_id)
|
||||
if (release_id !== "new") {
|
||||
try {
|
||||
let releaseData = await MusicModel.getReleaseData(release_id)
|
||||
|
||||
if (Array.isArray(releaseData.list)) {
|
||||
releaseData.list = releaseData.list.map((item) => {
|
||||
return new TrackManifest(item)
|
||||
})
|
||||
}
|
||||
if (Array.isArray(releaseData.items)) {
|
||||
releaseData.items = releaseData.items.map((item) => {
|
||||
return new TrackManifest(item)
|
||||
})
|
||||
}
|
||||
|
||||
setGlobalState({
|
||||
...globalState,
|
||||
...releaseData,
|
||||
})
|
||||
} catch (error) {
|
||||
setLoadError(error)
|
||||
}
|
||||
}
|
||||
setGlobalState({
|
||||
...globalState,
|
||||
...releaseData,
|
||||
})
|
||||
|
||||
setLoading(false)
|
||||
}
|
||||
setInitialValues(releaseData)
|
||||
} catch (error) {
|
||||
setLoadError(error)
|
||||
}
|
||||
}
|
||||
|
||||
async function renderCustomPage(page, actions) {
|
||||
setCustomPage(page ?? null)
|
||||
setCustomPageActions(actions ?? [])
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
setSubmitting(true)
|
||||
setSubmitError(null)
|
||||
function hasChanges() {
|
||||
const stagedChanges = {
|
||||
title: globalState.title,
|
||||
type: globalState.type,
|
||||
public: globalState.public,
|
||||
cover: globalState.cover,
|
||||
items: globalState.items,
|
||||
}
|
||||
|
||||
try {
|
||||
// first sumbit tracks
|
||||
const tracks = await MusicModel.putTrack({
|
||||
list: globalState.list,
|
||||
})
|
||||
return !compareObjectsByProperties(
|
||||
stagedChanges,
|
||||
initialValues,
|
||||
Object.keys(stagedChanges),
|
||||
)
|
||||
}
|
||||
|
||||
// then submit release
|
||||
const result = await MusicModel.putRelease({
|
||||
_id: globalState._id,
|
||||
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),
|
||||
})
|
||||
async function renderCustomPage(page, actions) {
|
||||
setCustomPage(page ?? null)
|
||||
setCustomPageActions(actions ?? [])
|
||||
}
|
||||
|
||||
app.location.push(`/studio/music/${result._id}`)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
app.message.error(error.message)
|
||||
async function handleSubmit() {
|
||||
setSubmitting(true)
|
||||
setSubmitError(null)
|
||||
|
||||
setSubmitError(error)
|
||||
setSubmitting(false)
|
||||
try {
|
||||
console.log("Submitting Tracks")
|
||||
|
||||
return false
|
||||
}
|
||||
// first sumbit tracks
|
||||
const tracks = await MusicModel.putTrack({
|
||||
items: globalState.items,
|
||||
})
|
||||
|
||||
setSubmitting(false)
|
||||
app.message.success("Release saved")
|
||||
}
|
||||
console.log("Submitting release")
|
||||
|
||||
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("/"))
|
||||
},
|
||||
})
|
||||
}
|
||||
// then submit release
|
||||
const result = await MusicModel.putRelease({
|
||||
_id: globalState._id,
|
||||
title: globalState.title,
|
||||
description: globalState.description,
|
||||
public: globalState.public,
|
||||
cover: globalState.cover,
|
||||
explicit: globalState.explicit,
|
||||
type: globalState.type,
|
||||
items: tracks.items.map((item) => item._id),
|
||||
})
|
||||
|
||||
async function canFinish() {
|
||||
return true
|
||||
}
|
||||
app.location.push(`/studio/music/${result._id}`)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
app.message.error(error.message)
|
||||
|
||||
React.useEffect(() => {
|
||||
initialize()
|
||||
}, [])
|
||||
setSubmitError(error)
|
||||
setSubmitting(false)
|
||||
|
||||
if (loadError) {
|
||||
return <antd.Result
|
||||
status="warning"
|
||||
title="Error"
|
||||
subTitle={loadError.message}
|
||||
/>
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <antd.Skeleton active />
|
||||
}
|
||||
setSubmitting(false)
|
||||
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 = {
|
||||
close: () => {
|
||||
renderCustomPage(null, null)
|
||||
}
|
||||
}
|
||||
function canFinish() {
|
||||
return hasChanges()
|
||||
}
|
||||
|
||||
return <ReleaseEditorStateContext.Provider
|
||||
value={{
|
||||
...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)}
|
||||
/>
|
||||
React.useEffect(() => {
|
||||
initialize()
|
||||
}, [])
|
||||
|
||||
<h2>{customPage.header}</h2>
|
||||
</div>
|
||||
if (loadError) {
|
||||
return (
|
||||
<antd.Result
|
||||
status="warning"
|
||||
title="Error"
|
||||
subTitle={loadError.message}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
Array.isArray(customPageActions) && customPageActions.map((action, index) => {
|
||||
return <antd.Button
|
||||
key={index}
|
||||
type={action.type}
|
||||
icon={createIconRender(action.icon)}
|
||||
onClick={async () => {
|
||||
if (typeof action.onClick === "function") {
|
||||
await action.onClick()
|
||||
}
|
||||
if (loading) {
|
||||
return <antd.Skeleton active />
|
||||
}
|
||||
|
||||
if (action.fireEvent) {
|
||||
app.eventBus.emit(action.fireEvent)
|
||||
}
|
||||
}}
|
||||
disabled={action.disabled}
|
||||
>
|
||||
{action.label}
|
||||
</antd.Button>
|
||||
})
|
||||
}
|
||||
</div>
|
||||
}
|
||||
const Tab = Tabs.find(({ key }) => key === selectedTab)
|
||||
|
||||
{
|
||||
customPage.content && (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"
|
||||
/>
|
||||
const CustomPageProps = {
|
||||
close: () => {
|
||||
renderCustomPage(null, null)
|
||||
},
|
||||
}
|
||||
|
||||
<div className="music-studio-release-editor-menu-actions">
|
||||
<antd.Button
|
||||
type="primary"
|
||||
onClick={handleSubmit}
|
||||
icon={release_id !== "new" ? <Icons.FiSave /> : <Icons.MdSend />}
|
||||
disabled={submitting || loading || !canFinish()}
|
||||
loading={submitting}
|
||||
>
|
||||
{release_id !== "new" ? "Save" : "Release"}
|
||||
</antd.Button>
|
||||
return (
|
||||
<ReleaseEditorStateContext.Provider
|
||||
value={{
|
||||
...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)
|
||||
}
|
||||
/>
|
||||
|
||||
{
|
||||
release_id !== "new" ? <antd.Button
|
||||
icon={<Icons.IoMdTrash />}
|
||||
disabled={loading}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
Delete
|
||||
</antd.Button> : null
|
||||
}
|
||||
<h2>{customPage.header}</h2>
|
||||
</div>
|
||||
|
||||
{
|
||||
release_id !== "new" ? <antd.Button
|
||||
icon={<Icons.MdLink />}
|
||||
onClick={() => app.location.push(`/music/release/${globalState._id}`)}
|
||||
>
|
||||
Go to release
|
||||
</antd.Button> : null
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
{Array.isArray(customPageActions) &&
|
||||
customPageActions.map((action, index) => {
|
||||
return (
|
||||
<antd.Button
|
||||
key={index}
|
||||
type={action.type}
|
||||
icon={createIconRender(
|
||||
action.icon,
|
||||
)}
|
||||
onClick={async () => {
|
||||
if (
|
||||
typeof action.onClick ===
|
||||
"function"
|
||||
) {
|
||||
await action.onClick()
|
||||
}
|
||||
|
||||
<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,
|
||||
if (action.fireEvent) {
|
||||
app.eventBus.emit(
|
||||
action.fireEvent,
|
||||
)
|
||||
}
|
||||
}}
|
||||
disabled={action.disabled}
|
||||
>
|
||||
{action.label}
|
||||
</antd.Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
state: globalState,
|
||||
setState: setGlobalState,
|
||||
{customPage.content &&
|
||||
(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: {
|
||||
basic: basicInfoRef
|
||||
}
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
</ReleaseEditorStateContext.Provider>
|
||||
<div className="music-studio-release-editor-menu-actions">
|
||||
<antd.Button
|
||||
type="primary"
|
||||
onClick={handleSubmit}
|
||||
icon={
|
||||
release_id !== "new" ? (
|
||||
<Icons.FiSave />
|
||||
) : (
|
||||
<Icons.MdSend />
|
||||
)
|
||||
}
|
||||
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
|
||||
|
@ -11,13 +11,27 @@ import { ReleaseEditorStateContext } from "@contexts/MusicReleaseEditor"
|
||||
|
||||
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 context = React.useContext(ReleaseEditorStateContext)
|
||||
|
||||
const [loading, setLoading] = React.useState(false)
|
||||
const [error, setError] = React.useState(null)
|
||||
|
||||
const { track } = props
|
||||
const { track, progress } = props
|
||||
|
||||
async function onClickEditTrack() {
|
||||
context.renderCustomPage({
|
||||
@ -33,8 +47,6 @@ const TrackListItem = (props) => {
|
||||
props.onDelete(track.uid)
|
||||
}
|
||||
|
||||
console.log("render")
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classnames(
|
||||
@ -50,7 +62,7 @@ const TrackListItem = (props) => {
|
||||
<div
|
||||
className="music-studio-release-editor-tracks-list-item-progress"
|
||||
style={{
|
||||
"--upload-progress": `${props.uploading.progress}%`,
|
||||
"--upload-progress": `${props.progress?.percent ?? 0}%`,
|
||||
}}
|
||||
/>
|
||||
|
||||
@ -58,7 +70,7 @@ const TrackListItem = (props) => {
|
||||
<span>{props.index + 1}</span>
|
||||
</div>
|
||||
|
||||
{props.uploading.working && <Icons.LoadingOutlined />}
|
||||
{progress !== null && <Icons.LoadingOutlined />}
|
||||
|
||||
<Image
|
||||
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">
|
||||
<antd.Popconfirm
|
||||
|
@ -17,12 +17,12 @@ class TracksManager extends React.Component {
|
||||
swapyRef = React.createRef()
|
||||
|
||||
state = {
|
||||
list: Array.isArray(this.props.list) ? this.props.list : [],
|
||||
items: Array.isArray(this.props.items) ? this.props.items : [],
|
||||
pendingUploads: [],
|
||||
}
|
||||
|
||||
componentDidUpdate = (prevProps, prevState) => {
|
||||
if (prevState.list !== this.state.list) {
|
||||
if (prevState.items !== this.state.items) {
|
||||
if (typeof this.props.onChangeState === "function") {
|
||||
this.props.onChangeState(this.state)
|
||||
}
|
||||
@ -55,7 +55,7 @@ class TracksManager extends React.Component {
|
||||
return false
|
||||
}
|
||||
|
||||
return this.state.list.find((item) => item.uid === uid)
|
||||
return this.state.items.find((item) => item.uid === uid)
|
||||
}
|
||||
|
||||
addTrackToList = (track) => {
|
||||
@ -64,7 +64,7 @@ class TracksManager extends React.Component {
|
||||
}
|
||||
|
||||
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.setState({
|
||||
list: this.state.list.filter((item) => item.uid !== uid),
|
||||
items: this.state.items.filter((item) => item.uid !== uid),
|
||||
})
|
||||
}
|
||||
|
||||
modifyTrackByUid = (uid, track) => {
|
||||
console.log("modifyTrackByUid", uid, track)
|
||||
if (!uid || !track) {
|
||||
return false
|
||||
}
|
||||
|
||||
this.setState({
|
||||
list: this.state.list.map((item) => {
|
||||
items: this.state.items.map((item) => {
|
||||
if (item.uid === uid) {
|
||||
return {
|
||||
...item,
|
||||
@ -140,7 +139,7 @@ class TracksManager extends React.Component {
|
||||
)
|
||||
|
||||
if (uploadProgressIndex === -1) {
|
||||
return 0
|
||||
return null
|
||||
}
|
||||
|
||||
return this.state.pendingUploads[uploadProgressIndex].progress
|
||||
@ -159,7 +158,7 @@ class TracksManager extends React.Component {
|
||||
|
||||
newData[uploadProgressIndex].progress = progress
|
||||
|
||||
console.log(`Updating progress for [${uid}] to [${progress}]`)
|
||||
console.log(`Updating progress for [${uid}] to >`, progress)
|
||||
|
||||
this.setState({
|
||||
pendingUploads: newData,
|
||||
@ -177,8 +176,7 @@ class TracksManager extends React.Component {
|
||||
|
||||
const trackManifest = new TrackManifest({
|
||||
uid: uid,
|
||||
file: change.file,
|
||||
onChange: this.modifyTrackByUid,
|
||||
file: change.file.originFileObj,
|
||||
})
|
||||
|
||||
this.addTrackToList(trackManifest)
|
||||
@ -189,7 +187,7 @@ class TracksManager extends React.Component {
|
||||
// remove pending file
|
||||
this.removeTrackUIDFromPendingUploads(uid)
|
||||
|
||||
let trackManifest = this.state.list.find(
|
||||
let trackManifest = this.state.items.find(
|
||||
(item) => item.uid === uid,
|
||||
)
|
||||
|
||||
@ -206,6 +204,23 @@ class TracksManager extends React.Component {
|
||||
trackManifest.source = change.file.response.url
|
||||
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)
|
||||
|
||||
break
|
||||
@ -231,9 +246,8 @@ class TracksManager extends React.Component {
|
||||
const response = await app.cores.remoteStorage
|
||||
.uploadFile(req.file, {
|
||||
onProgress: this.handleTrackFileUploadProgress,
|
||||
service: "b2",
|
||||
headers: {
|
||||
transmux: "a-dash",
|
||||
transformations: "a-dash",
|
||||
},
|
||||
})
|
||||
.catch((error) => {
|
||||
@ -258,17 +272,17 @@ class TracksManager extends React.Component {
|
||||
this.setState((prev) => {
|
||||
// move all list items by 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)
|
||||
return {
|
||||
list: orderedIds,
|
||||
items: orderedIds,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
render() {
|
||||
console.log(`Tracks List >`, this.state.list)
|
||||
console.log(`Tracks List >`, this.state.items)
|
||||
|
||||
return (
|
||||
<div className="music-studio-release-editor-tracks">
|
||||
@ -280,7 +294,7 @@ class TracksManager extends React.Component {
|
||||
accept="audio/*"
|
||||
multiple
|
||||
>
|
||||
{this.state.list.length === 0 ? (
|
||||
{this.state.items.length === 0 ? (
|
||||
<UploadHint />
|
||||
) : (
|
||||
<antd.Button
|
||||
@ -296,11 +310,11 @@ class TracksManager extends React.Component {
|
||||
id="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" />
|
||||
)}
|
||||
|
||||
{this.state.list.map((track, index) => {
|
||||
{this.state.items.map((track, index) => {
|
||||
const progress = this.getUploadProgress(track.uid)
|
||||
|
||||
return (
|
||||
@ -310,12 +324,7 @@ class TracksManager extends React.Component {
|
||||
track={track}
|
||||
onEdit={this.modifyTrackByUid}
|
||||
onDelete={this.removeTrackByUid}
|
||||
uploading={{
|
||||
progress: progress,
|
||||
working: this.state.pendingUploads.find(
|
||||
(item) => item.uid === track.uid,
|
||||
),
|
||||
}}
|
||||
progress={progress}
|
||||
disabled={progress > 0}
|
||||
/>
|
||||
</div>
|
||||
@ -336,7 +345,7 @@ const ReleaseTracks = (props) => {
|
||||
|
||||
<TracksManager
|
||||
_id={state._id}
|
||||
list={state.list}
|
||||
items={state.items}
|
||||
onChangeState={(managerState) => {
|
||||
setState({
|
||||
...state,
|
||||
|
@ -10,158 +10,163 @@ import { ReleaseEditorStateContext } from "@contexts/MusicReleaseEditor"
|
||||
import "./index.less"
|
||||
|
||||
const TrackEditor = (props) => {
|
||||
const context = React.useContext(ReleaseEditorStateContext)
|
||||
const [track, setTrack] = React.useState(props.track ?? {})
|
||||
const context = React.useContext(ReleaseEditorStateContext)
|
||||
const [track, setTrack] = React.useState(props.track ?? {})
|
||||
|
||||
async function handleChange(key, value) {
|
||||
setTrack((prev) => {
|
||||
return {
|
||||
...prev,
|
||||
[key]: value
|
||||
}
|
||||
})
|
||||
}
|
||||
async function handleChange(key, value) {
|
||||
setTrack((prev) => {
|
||||
return {
|
||||
...prev,
|
||||
[key]: value,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function openEnhancedLyricsEditor() {
|
||||
context.renderCustomPage({
|
||||
header: "Enhanced Lyrics",
|
||||
content: EnhancedLyricsEditor,
|
||||
props: {
|
||||
track: track,
|
||||
}
|
||||
})
|
||||
}
|
||||
async function openEnhancedLyricsEditor() {
|
||||
context.renderCustomPage({
|
||||
header: "Enhanced Lyrics",
|
||||
content: EnhancedLyricsEditor,
|
||||
props: {
|
||||
track: track,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function handleOnSave() {
|
||||
setTrack((prev) => {
|
||||
const listData = [...context.list]
|
||||
async function handleOnSave() {
|
||||
setTrack((prev) => {
|
||||
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) {
|
||||
return prev
|
||||
}
|
||||
if (trackIndex === -1) {
|
||||
return prev
|
||||
}
|
||||
|
||||
listData[trackIndex] = prev
|
||||
listData[trackIndex] = prev
|
||||
|
||||
context.setGlobalState({
|
||||
...context,
|
||||
list: listData
|
||||
})
|
||||
context.setGlobalState({
|
||||
...context,
|
||||
items: listData,
|
||||
})
|
||||
|
||||
return prev
|
||||
})
|
||||
}
|
||||
props.close()
|
||||
|
||||
React.useEffect(() => {
|
||||
context.setCustomPageActions([
|
||||
{
|
||||
label: "Save",
|
||||
icon: "FiSave",
|
||||
type: "primary",
|
||||
onClick: handleOnSave,
|
||||
disabled: props.track === track,
|
||||
},
|
||||
])
|
||||
}, [track])
|
||||
return prev
|
||||
})
|
||||
}
|
||||
|
||||
return <div className="track-editor">
|
||||
<div className="track-editor-field">
|
||||
<div className="track-editor-field-header">
|
||||
<Icons.MdImage />
|
||||
<span>Cover</span>
|
||||
</div>
|
||||
function setParentCover() {
|
||||
handleChange("cover", context.cover)
|
||||
}
|
||||
|
||||
<CoverEditor
|
||||
value={track.cover}
|
||||
onChange={(url) => handleChange("cover", url)}
|
||||
extraActions={[
|
||||
<antd.Button>
|
||||
Use Parent
|
||||
</antd.Button>
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
React.useEffect(() => {
|
||||
context.setCustomPageActions([
|
||||
{
|
||||
label: "Save",
|
||||
icon: "FiSave",
|
||||
type: "primary",
|
||||
onClick: handleOnSave,
|
||||
disabled: props.track === track,
|
||||
},
|
||||
])
|
||||
}, [track])
|
||||
|
||||
<div className="track-editor-field">
|
||||
<div className="track-editor-field-header">
|
||||
<Icons.MdOutlineMusicNote />
|
||||
<span>Title</span>
|
||||
</div>
|
||||
return (
|
||||
<div className="track-editor">
|
||||
<div className="track-editor-field">
|
||||
<div className="track-editor-field-header">
|
||||
<Icons.MdImage />
|
||||
<span>Cover</span>
|
||||
</div>
|
||||
|
||||
<antd.Input
|
||||
value={track.title}
|
||||
placeholder="Track title"
|
||||
onChange={(e) => handleChange("title", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<CoverEditor
|
||||
value={track.cover}
|
||||
onChange={(url) => handleChange("cover", url)}
|
||||
extraActions={[
|
||||
<antd.Button onClick={setParentCover}>
|
||||
Use Parent
|
||||
</antd.Button>,
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="track-editor-field">
|
||||
<div className="track-editor-field-header">
|
||||
<Icons.FiUser />
|
||||
<span>Artist</span>
|
||||
</div>
|
||||
<div className="track-editor-field">
|
||||
<div className="track-editor-field-header">
|
||||
<Icons.MdOutlineMusicNote />
|
||||
<span>Title</span>
|
||||
</div>
|
||||
|
||||
<antd.Input
|
||||
value={track.artists?.join(", ")}
|
||||
placeholder="Artist"
|
||||
onChange={(e) => handleChange("artist", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<antd.Input
|
||||
value={track.title}
|
||||
placeholder="Track title"
|
||||
onChange={(e) => handleChange("title", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="track-editor-field">
|
||||
<div className="track-editor-field-header">
|
||||
<Icons.MdAlbum />
|
||||
<span>Album</span>
|
||||
</div>
|
||||
<div className="track-editor-field">
|
||||
<div className="track-editor-field-header">
|
||||
<Icons.FiUser />
|
||||
<span>Artist</span>
|
||||
</div>
|
||||
|
||||
<antd.Input
|
||||
value={track.album}
|
||||
placeholder="Album"
|
||||
onChange={(e) => handleChange("album", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<antd.Input
|
||||
value={track.artist}
|
||||
placeholder="Artist"
|
||||
onChange={(e) => handleChange("artist", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="track-editor-field">
|
||||
<div className="track-editor-field-header">
|
||||
<Icons.MdExplicit />
|
||||
<span>Explicit</span>
|
||||
</div>
|
||||
<div className="track-editor-field">
|
||||
<div className="track-editor-field-header">
|
||||
<Icons.MdAlbum />
|
||||
<span>Album</span>
|
||||
</div>
|
||||
|
||||
<antd.Switch
|
||||
checked={track.explicit}
|
||||
onChange={(value) => handleChange("explicit", value)}
|
||||
/>
|
||||
</div>
|
||||
<antd.Input
|
||||
value={track.album}
|
||||
placeholder="Album"
|
||||
onChange={(e) => handleChange("album", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="track-editor-field">
|
||||
<div className="track-editor-field-header">
|
||||
<Icons.MdLyrics />
|
||||
<span>Enhanced Lyrics</span>
|
||||
<div className="track-editor-field">
|
||||
<div className="track-editor-field-header">
|
||||
<Icons.MdExplicit />
|
||||
<span>Explicit</span>
|
||||
</div>
|
||||
|
||||
<antd.Switch
|
||||
checked={track.lyrics_enabled}
|
||||
onChange={(value) => handleChange("lyrics_enabled", value)}
|
||||
disabled={!track.params._id}
|
||||
/>
|
||||
</div>
|
||||
<antd.Switch
|
||||
checked={track.explicit}
|
||||
onChange={(value) => handleChange("explicit", value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="track-editor-field-actions">
|
||||
<antd.Button
|
||||
disabled={!track.params._id}
|
||||
onClick={openEnhancedLyricsEditor}
|
||||
>
|
||||
Edit
|
||||
</antd.Button>
|
||||
<div className="track-editor-field">
|
||||
<div className="track-editor-field-header">
|
||||
<Icons.MdLyrics />
|
||||
<span>Enhanced Lyrics</span>
|
||||
</div>
|
||||
|
||||
{
|
||||
!track.params._id && <span>
|
||||
You cannot edit Video and Lyrics without release first
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="track-editor-field-actions">
|
||||
<antd.Button
|
||||
disabled={!track.params._id}
|
||||
onClick={openEnhancedLyricsEditor}
|
||||
>
|
||||
Edit
|
||||
</antd.Button>
|
||||
|
||||
{!track.params._id && (
|
||||
<span>
|
||||
You cannot edit Video and Lyrics without release
|
||||
first
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TrackEditor
|
||||
export default TrackEditor
|
||||
|
@ -6,41 +6,51 @@ import LikeButton from "@components/LikeButton"
|
||||
|
||||
import { usePlayerStateContext } from "@contexts/WithPlayerContext"
|
||||
|
||||
import "./index.less"
|
||||
|
||||
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 () => {
|
||||
if (!playerState.track_manifest) {
|
||||
if (!trackInstance) {
|
||||
console.error("Cannot like a track if nothing is playing")
|
||||
return false
|
||||
}
|
||||
|
||||
const track = app.cores.player.track()
|
||||
|
||||
await track.manifest.serviceOperations.toggleItemFavourite(
|
||||
await trackInstance.manifest.serviceOperations.toggleItemFavourite(
|
||||
"track",
|
||||
playerState.track_manifest._id,
|
||||
trackInstance.manifest._id,
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="extra_actions">
|
||||
<div className="player-actions">
|
||||
{app.isMobile && (
|
||||
<Button
|
||||
type="ghost"
|
||||
icon={<Icons.MdAbc />}
|
||||
disabled={!playerState.track_manifest?.lyrics_enabled}
|
||||
disabled={!trackInstance?.manifest?.lyrics_enabled}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!app.isMobile && (
|
||||
<LikeButton
|
||||
liked={
|
||||
playerState.track_manifest?.serviceOperations
|
||||
.fetchLikeStatus
|
||||
trackInstance?.manifest?.serviceOperations
|
||||
?.fetchLikeStatus
|
||||
}
|
||||
onClick={handleClickLike}
|
||||
disabled={!playerState.track_manifest?._id}
|
||||
disabled={!trackInstance?.manifest?._id}
|
||||
/>
|
||||
)}
|
||||
|
19
packages/app/src/components/Player/Actions/index.less
Normal file
19
packages/app/src/components/Player/Actions/index.less
Normal 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;
|
||||
}
|
@ -47,7 +47,17 @@ const EventsHandlers = {
|
||||
}
|
||||
|
||||
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) => {
|
||||
if (typeof EventsHandlers[event] !== "function") {
|
||||
@ -122,10 +132,11 @@ const Controls = (props) => {
|
||||
{app.isMobile && (
|
||||
<LikeButton
|
||||
liked={
|
||||
playerState.track_manifest?.serviceOperations
|
||||
.fetchLikeStatus
|
||||
trackInstance?.manifest?.serviceOperations
|
||||
?.fetchLikeStatus
|
||||
}
|
||||
onClick={() => handleAction("like")}
|
||||
disabled={!trackInstance?.manifest?._id}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
@ -8,11 +8,10 @@ import { usePlayerStateContext } from "@contexts/WithPlayerContext"
|
||||
import LiveInfo from "@components/Player/LiveInfo"
|
||||
import SeekBar from "@components/Player/SeekBar"
|
||||
import Controls from "@components/Player/Controls"
|
||||
import Actions from "@components/Player/Actions"
|
||||
|
||||
import RGBStringToValues from "@utils/rgbToValues"
|
||||
|
||||
import ExtraActions from "../ExtraActions"
|
||||
|
||||
import "./index.less"
|
||||
|
||||
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 ?? {}
|
||||
|
||||
const playing = playerState.playback_status === "playing"
|
||||
@ -201,7 +200,7 @@ const Player = (props) => {
|
||||
)}
|
||||
|
||||
<p className="toolbar_player_info_subtitle">
|
||||
{artistStr ?? ""}
|
||||
{artist ?? ""}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -218,7 +217,7 @@ const Player = (props) => {
|
||||
streamMode={playerState.live}
|
||||
/>
|
||||
|
||||
<ExtraActions streamMode={playerState.live} />
|
||||
<Actions streamMode={playerState.live} />
|
||||
</div>
|
||||
|
||||
<Indicators
|
||||
|
@ -7,299 +7,265 @@
|
||||
@toolbar_player_top_actions_padding_horizontal: 15px;
|
||||
|
||||
.toolbar_player_wrapper {
|
||||
position: relative;
|
||||
position: relative;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
width: 100%;
|
||||
width: 100%;
|
||||
|
||||
transition: all 150ms ease-in-out;
|
||||
transition: all 150ms ease-in-out;
|
||||
|
||||
&.hover {
|
||||
filter: drop-shadow(@card-drop-shadow);
|
||||
&.hover {
|
||||
filter: drop-shadow(@card-drop-shadow);
|
||||
|
||||
.toolbar_player_top_actions {
|
||||
height: @toolbar_player_top_actions_height;
|
||||
padding: @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);
|
||||
}
|
||||
}
|
||||
.toolbar_player_top_actions {
|
||||
height: @toolbar_player_top_actions_height;
|
||||
padding: @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 {
|
||||
.toolbar_player_content {
|
||||
color: var(--text-color-black);
|
||||
}
|
||||
&.cover_light {
|
||||
.toolbar_player_content {
|
||||
color: var(--text-color-black);
|
||||
}
|
||||
|
||||
.MuiSlider-root {
|
||||
color: var(--text-color-black);
|
||||
.MuiSlider-root {
|
||||
color: var(--text-color-black);
|
||||
|
||||
.MuiSlider-rail {
|
||||
color: var(--text-color-black);
|
||||
}
|
||||
}
|
||||
.MuiSlider-rail {
|
||||
color: var(--text-color-black);
|
||||
}
|
||||
}
|
||||
|
||||
.loadCircle {
|
||||
svg {
|
||||
path {
|
||||
stroke: var(--text-color-black);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.loadCircle {
|
||||
svg {
|
||||
path {
|
||||
stroke: var(--text-color-black);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar_player_top_actions {
|
||||
position: relative;
|
||||
position: relative;
|
||||
|
||||
z-index: 60;
|
||||
z-index: 60;
|
||||
|
||||
display: inline-flex;
|
||||
flex-direction: row;
|
||||
display: inline-flex;
|
||||
flex-direction: row;
|
||||
|
||||
width: 100%;
|
||||
height: 0px;
|
||||
width: 100%;
|
||||
height: 0px;
|
||||
|
||||
gap: 20px;
|
||||
gap: 20px;
|
||||
|
||||
transition: all 150ms ease-in-out;
|
||||
transform: translateY(calc(@toolbar_player_borderRadius / 2));
|
||||
overflow: hidden;
|
||||
transition: all 150ms ease-in-out;
|
||||
transform: translateY(calc(@toolbar_player_borderRadius / 2));
|
||||
overflow: hidden;
|
||||
|
||||
background-color: var(--background-color-primary);
|
||||
border-radius: 12px 12px 0 0;
|
||||
background-color: var(--background-color-primary);
|
||||
border-radius: 12px 12px 0 0;
|
||||
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.toolbar_player {
|
||||
position: relative;
|
||||
position: relative;
|
||||
|
||||
display: flex;
|
||||
display: flex;
|
||||
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
width: 100%;
|
||||
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 {
|
||||
position: absolute;
|
||||
.toolbar_cover_background {
|
||||
position: absolute;
|
||||
|
||||
top: 0;
|
||||
left: 0;
|
||||
top: 0;
|
||||
left: 0;
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
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
|
||||
//-webkit-mask-image: linear-gradient(to top, rgba(0, 0, 0, 0), rgba(0, 0, 0, 1));
|
||||
}
|
||||
// create a mask to the bottom
|
||||
//-webkit-mask-image: linear-gradient(to top, rgba(0, 0, 0, 0), rgba(0, 0, 0, 1));
|
||||
}
|
||||
|
||||
.toolbar_player_content {
|
||||
position: relative;
|
||||
.toolbar_player_content {
|
||||
position: relative;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
width: 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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
.toolbar_player_info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
color: currentColor;
|
||||
color: currentColor;
|
||||
|
||||
.toolbar_player_info_title {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
.toolbar_player_info_title {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
margin: 0;
|
||||
margin-right: 30px;
|
||||
margin: 0;
|
||||
margin-right: 30px;
|
||||
|
||||
width: fit-content;
|
||||
width: fit-content;
|
||||
|
||||
overflow: hidden;
|
||||
overflow: hidden;
|
||||
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
font-size: 1.5rem;
|
||||
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;
|
||||
white-space: nowrap;
|
||||
word-break: break-all;
|
||||
white-space: nowrap;
|
||||
|
||||
&.overflown {
|
||||
opacity: 0;
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
&.overflown {
|
||||
opacity: 0;
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar_player_info_subtitle {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 400;
|
||||
.toolbar_player_info_subtitle {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 400;
|
||||
|
||||
color: currentColor;
|
||||
}
|
||||
}
|
||||
color: currentColor;
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar_player_actions {
|
||||
position: absolute;
|
||||
.toolbar_player_actions {
|
||||
position: absolute;
|
||||
|
||||
color: currentColor;
|
||||
color: currentColor;
|
||||
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
|
||||
height: fit-content;
|
||||
width: 100%;
|
||||
height: fit-content;
|
||||
width: 100%;
|
||||
|
||||
padding: 10px;
|
||||
gap: 5px;
|
||||
padding: 10px;
|
||||
gap: 5px;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.player-controls {
|
||||
color: currentColor;
|
||||
.player-controls {
|
||||
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);
|
||||
// backdrop-filter: blur(5px);
|
||||
// -webkit-backdrop-filter: blur(5px);
|
||||
// backdrop-filter: blur(5px);
|
||||
|
||||
// border-radius: 12px;
|
||||
// border-radius: 12px;
|
||||
|
||||
.ant-btn-icon,
|
||||
button {
|
||||
color: currentColor;
|
||||
.ant-btn-icon,
|
||||
button {
|
||||
color: currentColor;
|
||||
|
||||
svg {
|
||||
color: currentColor;
|
||||
}
|
||||
}
|
||||
}
|
||||
svg {
|
||||
color: currentColor;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.player-seek_bar {
|
||||
height: fit-content;
|
||||
margin: 0;
|
||||
.player-seek_bar {
|
||||
height: fit-content;
|
||||
margin: 0;
|
||||
|
||||
color: currentColor;
|
||||
color: currentColor;
|
||||
|
||||
.timers {
|
||||
color: currentColor;
|
||||
.timers {
|
||||
color: currentColor;
|
||||
|
||||
span {
|
||||
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;
|
||||
}
|
||||
span {
|
||||
color: currentColor;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar_player_indicators_wrapper {
|
||||
position: absolute;
|
||||
position: absolute;
|
||||
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
|
||||
padding: 10px;
|
||||
padding: 10px;
|
||||
|
||||
.toolbar_player_indicators {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
.toolbar_player_indicators {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: fit-content;
|
||||
|
||||
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);
|
||||
-webkit-backdrop-filter: blur(5px);
|
||||
backdrop-filter: blur(5px);
|
||||
font-size: 1rem;
|
||||
|
||||
font-size: 1rem;
|
||||
svg {
|
||||
margin: 0 !important;
|
||||
|
||||
svg {
|
||||
margin: 0 !important;
|
||||
|
||||
color: white
|
||||
}
|
||||
}
|
||||
}
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -110,6 +110,7 @@ export class PostsListsComponent extends React.Component {
|
||||
|
||||
hasMore: true,
|
||||
list: this.props.list ?? [],
|
||||
pageCount: 0,
|
||||
}
|
||||
|
||||
parentRef = this.props.innerRef
|
||||
@ -148,12 +149,17 @@ export class PostsListsComponent extends React.Component {
|
||||
}
|
||||
|
||||
handleLoad = async (fn, params = {}) => {
|
||||
if (this.state.loading === true) {
|
||||
console.warn(`Please wait to load the post before load more`)
|
||||
return
|
||||
}
|
||||
|
||||
this.setState({
|
||||
loading: true,
|
||||
})
|
||||
|
||||
let payload = {
|
||||
trim: this.state.list.length,
|
||||
page: this.state.pageCount,
|
||||
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) => {
|
||||
console.error(err)
|
||||
|
||||
@ -186,10 +188,12 @@ export class PostsListsComponent extends React.Component {
|
||||
if (params.replace) {
|
||||
this.setState({
|
||||
list: result,
|
||||
pageCount: 0,
|
||||
})
|
||||
} else {
|
||||
this.setState({
|
||||
list: [...this.state.list, ...result],
|
||||
pageCount: this.state.pageCount + 1,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -7,112 +7,102 @@ import { Icons } from "@components/Icons"
|
||||
import "./index.less"
|
||||
|
||||
export default (props) => {
|
||||
const [uploading, setUploading] = React.useState(false)
|
||||
const [progess, setProgess] = React.useState(null)
|
||||
const [uploading, setUploading] = React.useState(false)
|
||||
const [progress, setProgress] = React.useState(null)
|
||||
|
||||
const handleOnStart = (file_uid, file) => {
|
||||
if (typeof props.onStart === "function") {
|
||||
props.onStart(file_uid, file)
|
||||
}
|
||||
}
|
||||
const handleOnStart = (file_uid, file) => {
|
||||
if (typeof props.onStart === "function") {
|
||||
props.onStart(file_uid, file)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOnProgress = (file_uid, progress) => {
|
||||
if (typeof props.onProgress === "function") {
|
||||
props.onProgress(file_uid, progress)
|
||||
}
|
||||
}
|
||||
const handleOnProgress = (file_uid, progress) => {
|
||||
if (typeof props.onProgress === "function") {
|
||||
props.onProgress(file_uid, progress)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOnError = (file_uid, error) => {
|
||||
if (typeof props.onError === "function") {
|
||||
props.onError(file_uid, error)
|
||||
}
|
||||
}
|
||||
const handleOnError = (file_uid, error) => {
|
||||
if (typeof props.onError === "function") {
|
||||
props.onError(file_uid, error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOnSuccess = (file_uid, response) => {
|
||||
if (typeof props.onSuccess === "function") {
|
||||
props.onSuccess(file_uid, response)
|
||||
}
|
||||
}
|
||||
const handleOnSuccess = (file_uid, response) => {
|
||||
if (typeof props.onSuccess === "function") {
|
||||
props.onSuccess(file_uid, response)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpload = async (req) => {
|
||||
setUploading(true)
|
||||
setProgess(1)
|
||||
const handleUpload = async (req) => {
|
||||
setUploading(true)
|
||||
setProgress(1)
|
||||
|
||||
handleOnStart(req.file.uid, req.file)
|
||||
handleOnStart(req.file.uid, req.file)
|
||||
|
||||
await app.cores.remoteStorage.uploadFile(req.file, {
|
||||
headers: props.headers,
|
||||
onProgress: (file, progress) => {
|
||||
setProgess(progress)
|
||||
handleOnProgress(file.uid, progress)
|
||||
},
|
||||
onError: (file, error) => {
|
||||
setProgess(null)
|
||||
handleOnError(file.uid, error)
|
||||
setUploading(false)
|
||||
},
|
||||
onFinish: (file, response) => {
|
||||
if (typeof props.ctx?.onUpdateItem === "function") {
|
||||
props.ctx.onUpdateItem(response.url)
|
||||
}
|
||||
await app.cores.remoteStorage.uploadFile(req.file, {
|
||||
headers: props.headers,
|
||||
onProgress: (file, progress) => {
|
||||
setProgress(progress)
|
||||
handleOnProgress(file.uid, progress)
|
||||
},
|
||||
onError: (file, error) => {
|
||||
setProgress(null)
|
||||
handleOnError(file.uid, error)
|
||||
setUploading(false)
|
||||
},
|
||||
onFinish: (file, response) => {
|
||||
if (typeof props.ctx?.onUpdateItem === "function") {
|
||||
props.ctx.onUpdateItem(response.url)
|
||||
}
|
||||
|
||||
if (typeof props.onUploadDone === "function") {
|
||||
props.onUploadDone(response)
|
||||
}
|
||||
if (typeof props.onUploadDone === "function") {
|
||||
props.onUploadDone(response)
|
||||
}
|
||||
|
||||
setUploading(false)
|
||||
handleOnSuccess(req.file.uid, response)
|
||||
setUploading(false)
|
||||
handleOnSuccess(req.file.uid, response)
|
||||
|
||||
setTimeout(() => {
|
||||
setProgess(null)
|
||||
}, 1000)
|
||||
},
|
||||
})
|
||||
}
|
||||
setTimeout(() => {
|
||||
setProgress(null)
|
||||
}, 1000)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return <Upload
|
||||
customRequest={handleUpload}
|
||||
multiple={
|
||||
props.multiple ?? false
|
||||
}
|
||||
accept={
|
||||
props.accept ?? [
|
||||
"image/*",
|
||||
"video/*",
|
||||
"audio/*",
|
||||
]
|
||||
}
|
||||
progress={false}
|
||||
fileList={[]}
|
||||
className={classnames(
|
||||
"uploadButton",
|
||||
{
|
||||
["uploading"]: !!progess || uploading
|
||||
}
|
||||
)}
|
||||
disabled={uploading}
|
||||
>
|
||||
<div className="uploadButton-content">
|
||||
{
|
||||
!progess && (props.icon ?? <Icons.FiUpload
|
||||
style={{
|
||||
margin: 0
|
||||
}}
|
||||
/>)
|
||||
}
|
||||
return (
|
||||
<Upload
|
||||
customRequest={handleUpload}
|
||||
multiple={props.multiple ?? false}
|
||||
accept={props.accept ?? ["image/*", "video/*", "audio/*"]}
|
||||
progress={false}
|
||||
fileList={[]}
|
||||
className={classnames("uploadButton", {
|
||||
["uploading"]: !!progress || uploading,
|
||||
})}
|
||||
disabled={uploading}
|
||||
>
|
||||
<div className="uploadButton-content">
|
||||
{!progress &&
|
||||
(props.icon ?? (
|
||||
<Icons.FiUpload
|
||||
style={{
|
||||
margin: 0,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{
|
||||
progess && <Progress
|
||||
type="circle"
|
||||
percent={progess}
|
||||
strokeWidth={20}
|
||||
format={() => null}
|
||||
/>
|
||||
}
|
||||
{progress && (
|
||||
<Progress
|
||||
type="circle"
|
||||
percent={progress?.percent ?? 0}
|
||||
strokeWidth={20}
|
||||
format={() => null}
|
||||
/>
|
||||
)}
|
||||
|
||||
{
|
||||
props.children ?? "Upload"
|
||||
}
|
||||
</div>
|
||||
</Upload>
|
||||
}
|
||||
{props.children ?? "Upload"}
|
||||
</div>
|
||||
</Upload>
|
||||
)
|
||||
}
|
||||
|
@ -1,17 +1,19 @@
|
||||
import React from "react"
|
||||
|
||||
export const DefaultReleaseEditorState = {
|
||||
cover: null,
|
||||
title: "Untitled",
|
||||
type: "single",
|
||||
public: false,
|
||||
cover: null,
|
||||
title: "Untitled",
|
||||
type: "single",
|
||||
public: false,
|
||||
|
||||
list: [],
|
||||
pendingUploads: [],
|
||||
items: [],
|
||||
pendingUploads: [],
|
||||
|
||||
setCustomPage: () => {},
|
||||
setCustomPage: () => {},
|
||||
}
|
||||
|
||||
export const ReleaseEditorStateContext = React.createContext(DefaultReleaseEditorState)
|
||||
export const ReleaseEditorStateContext = React.createContext(
|
||||
DefaultReleaseEditorState,
|
||||
)
|
||||
|
||||
export default ReleaseEditorStateContext
|
||||
export default ReleaseEditorStateContext
|
||||
|
123
packages/app/src/cores/player/classes/AudioBase.js
Normal file
123
packages/app/src/cores/player/classes/AudioBase.js
Normal 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)
|
||||
},
|
||||
}
|
||||
}
|
56
packages/app/src/cores/player/classes/MediaSession.js
Normal file
56
packages/app/src/cores/player/classes/MediaSession.js
Normal 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"
|
||||
}
|
||||
}
|
@ -1,83 +1,96 @@
|
||||
import defaultAudioProccessors from "../processors"
|
||||
|
||||
export default class PlayerProcessors {
|
||||
constructor(player) {
|
||||
this.player = player
|
||||
}
|
||||
constructor(base) {
|
||||
this.base = base
|
||||
}
|
||||
|
||||
processors = []
|
||||
nodes = []
|
||||
attached = []
|
||||
|
||||
public = {}
|
||||
public = {}
|
||||
|
||||
async initialize() {
|
||||
// if already exists audio processors, destroy all before create new
|
||||
if (this.processors.length > 0) {
|
||||
this.player.console.log("Destroying audio processors")
|
||||
async initialize() {
|
||||
// if already exists audio processors, destroy all before create new
|
||||
if (this.nodes.length > 0) {
|
||||
this.base.player.console.log("Destroying audio processors")
|
||||
|
||||
this.processors.forEach((processor) => {
|
||||
this.player.console.log(`Destroying audio processor ${processor.constructor.name}`, processor)
|
||||
processor._destroy()
|
||||
})
|
||||
this.nodes.forEach((node) => {
|
||||
this.base.player.console.log(
|
||||
`Destroying audio processor node ${node.constructor.name}`,
|
||||
node,
|
||||
)
|
||||
node._destroy()
|
||||
})
|
||||
|
||||
this.processors = []
|
||||
}
|
||||
this.nodes = []
|
||||
}
|
||||
|
||||
// instanciate default audio processors
|
||||
for await (const defaultProccessor of defaultAudioProccessors) {
|
||||
this.processors.push(new defaultProccessor(this.player))
|
||||
}
|
||||
// instanciate default audio processors
|
||||
for await (const defaultProccessor of defaultAudioProccessors) {
|
||||
this.nodes.push(new defaultProccessor(this))
|
||||
}
|
||||
|
||||
// initialize audio processors
|
||||
for await (const processor of this.processors) {
|
||||
if (typeof processor._init === "function") {
|
||||
try {
|
||||
await processor._init(this.player.audioContext)
|
||||
} catch (error) {
|
||||
this.player.console.error(`Failed to initialize audio processor ${processor.constructor.name} >`, error)
|
||||
continue
|
||||
}
|
||||
}
|
||||
// initialize audio processors
|
||||
for await (const node of this.nodes) {
|
||||
if (typeof node._init === "function") {
|
||||
try {
|
||||
await node._init()
|
||||
} catch (error) {
|
||||
this.base.player.console.error(
|
||||
`Failed to initialize audio processor node ${node.constructor.name} >`,
|
||||
error,
|
||||
)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// check if processor has exposed public methods
|
||||
if (processor.exposeToPublic) {
|
||||
Object.entries(processor.exposeToPublic).forEach(([key, value]) => {
|
||||
const refName = processor.constructor.refName
|
||||
// check if processor has exposed public methods
|
||||
if (node.exposeToPublic) {
|
||||
Object.entries(node.exposeToPublic).forEach(([key, value]) => {
|
||||
const refName = node.constructor.refName
|
||||
|
||||
if (typeof this.player.public[refName] === "undefined") {
|
||||
// by default create a empty object
|
||||
this.player.public[refName] = {}
|
||||
}
|
||||
if (typeof this.base.processors[refName] === "undefined") {
|
||||
// by default create a empty object
|
||||
this.base.processors[refName] = {}
|
||||
}
|
||||
|
||||
this.player.public[refName][key] = value
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
this.base.processors[refName][key] = value
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async attachProcessorsToInstance(instance) {
|
||||
for await (const [index, processor] of this.processors.entries()) {
|
||||
if (processor.constructor.node_bypass === true) {
|
||||
instance.contextElement.connect(processor.processor)
|
||||
attachAllNodes = async () => {
|
||||
for await (const [index, node] of this.nodes.entries()) {
|
||||
if (node.constructor.node_bypass === true) {
|
||||
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") {
|
||||
this.player.console.error(`Processor ${processor.constructor.refName} not support attach`)
|
||||
if (typeof node._attach !== "function") {
|
||||
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
|
||||
lastProcessor.connect(this.player.audioContext.destination)
|
||||
// now attach to destination
|
||||
lastProcessor.connect(this.base.context.destination)
|
||||
}
|
||||
|
||||
return instance
|
||||
}
|
||||
}
|
||||
detachAllNodes = async () => {
|
||||
for (const [index, node] of this.attached.entries()) {
|
||||
await node._detach()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,206 +1,131 @@
|
||||
import TrackManifest from "./TrackManifest"
|
||||
import { MediaPlayer } from "dashjs"
|
||||
|
||||
export default class TrackInstance {
|
||||
constructor(player, manifest) {
|
||||
constructor(manifest, player) {
|
||||
if (typeof manifest === "undefined") {
|
||||
throw new Error("Manifest is required")
|
||||
}
|
||||
|
||||
if (!player) {
|
||||
throw new Error("Player core is required")
|
||||
}
|
||||
|
||||
if (typeof manifest === "undefined") {
|
||||
throw new Error("Manifest is required")
|
||||
if (!(manifest instanceof TrackManifest)) {
|
||||
manifest = new TrackManifest(manifest, player)
|
||||
}
|
||||
|
||||
if (!manifest.source) {
|
||||
throw new Error("Manifest must have a source")
|
||||
}
|
||||
|
||||
this.player = player
|
||||
this.manifest = manifest
|
||||
|
||||
this.id = this.manifest.id ?? this.manifest._id
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
_initialized = false
|
||||
play = async (params = {}) => {
|
||||
const startTime = performance.now()
|
||||
|
||||
audio = null
|
||||
|
||||
contextElement = null
|
||||
|
||||
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)
|
||||
if (!this.manifest.source.endsWith(".mpd")) {
|
||||
this.player.base.demuxer.destroy()
|
||||
this.player.base.audio.src = this.manifest.source
|
||||
} else {
|
||||
this.audio.src = this.manifest.source
|
||||
}
|
||||
|
||||
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`,
|
||||
)
|
||||
if (!this.player.base.demuxer) {
|
||||
this.player.base.createDemuxer()
|
||||
}
|
||||
|
||||
// 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,
|
||||
await this.player.base.demuxer.attachSource(
|
||||
`${this.manifest.source}?t=${Date.now()}`,
|
||||
)
|
||||
|
||||
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
|
||||
// }
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import jsmediatags from "jsmediatags/dist/jsmediatags.min.js"
|
||||
import { parseBlob } from "music-metadata"
|
||||
import { FastAverageColor } from "fast-average-color"
|
||||
|
||||
export default class TrackManifest {
|
||||
@ -33,13 +33,6 @@ export default class TrackManifest {
|
||||
this.artist = params.artist
|
||||
}
|
||||
|
||||
if (
|
||||
typeof params.artists !== "undefined" ||
|
||||
Array.isArray(params.artists)
|
||||
) {
|
||||
this.artistStr = params.artists.join(", ")
|
||||
}
|
||||
|
||||
if (typeof params.source !== "undefined") {
|
||||
this.source = params.source
|
||||
}
|
||||
@ -48,8 +41,8 @@ export default class TrackManifest {
|
||||
this.metadata = params.metadata
|
||||
}
|
||||
|
||||
if (typeof params.lyrics_enabled !== "undefined") {
|
||||
this.lyrics_enabled = params.lyrics_enabled
|
||||
if (typeof params.liked !== "undefined") {
|
||||
this.liked = params.liked
|
||||
}
|
||||
|
||||
return this
|
||||
@ -58,87 +51,45 @@ export default class TrackManifest {
|
||||
_id = null // used for api requests
|
||||
uid = null // used for internal
|
||||
|
||||
cover =
|
||||
"https://storage.ragestudio.net/comty-static-assets/default_song.png"
|
||||
title = "Untitled"
|
||||
album = "Unknown"
|
||||
artist = "Unknown"
|
||||
cover = null // set default cover url
|
||||
source = null
|
||||
metadata = null
|
||||
metadata = {}
|
||||
|
||||
// set default service to default
|
||||
service = "default"
|
||||
|
||||
// Extended from db
|
||||
lyrics_enabled = false
|
||||
liked = null
|
||||
|
||||
async initialize() {
|
||||
if (this.params.file) {
|
||||
this.metadata = await this.analyzeMetadata(
|
||||
this.params.file.originFileObj,
|
||||
)
|
||||
if (!this.params.file) {
|
||||
return this
|
||||
}
|
||||
|
||||
this.metadata.format = this.metadata.type.toUpperCase()
|
||||
const analyzedMetadata = await parseBlob(this.params.file, {
|
||||
skipPostHeaders: true,
|
||||
}).catch(() => ({}))
|
||||
|
||||
if (this.metadata.tags) {
|
||||
if (this.metadata.tags.title) {
|
||||
this.title = this.metadata.tags.title
|
||||
}
|
||||
if (analyzedMetadata.format) {
|
||||
this.metadata.format = analyzedMetadata.format.codec
|
||||
}
|
||||
|
||||
if (this.metadata.tags.artist) {
|
||||
this.artist = this.metadata.tags.artist
|
||||
}
|
||||
if (analyzedMetadata.common) {
|
||||
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) {
|
||||
this.album = this.metadata.tags.album
|
||||
}
|
||||
if (analyzedMetadata.common.picture) {
|
||||
const cover = analyzedMetadata.common.picture[0]
|
||||
|
||||
if (this.metadata.tags.picture) {
|
||||
this.cover = app.cores.remoteStorage.binaryArrayToFile(
|
||||
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,
|
||||
})
|
||||
}
|
||||
this._coverBlob = new Blob([cover.data], { type: cover.format })
|
||||
this.cover = URL.createObjectURL(this._coverBlob)
|
||||
}
|
||||
|
||||
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 () => {
|
||||
const fac = new FastAverageColor()
|
||||
|
||||
@ -169,8 +120,6 @@ export default class TrackManifest {
|
||||
this,
|
||||
)
|
||||
|
||||
console.log(this.overrides)
|
||||
|
||||
if (this.overrides) {
|
||||
return {
|
||||
...result,
|
||||
@ -210,6 +159,7 @@ export default class TrackManifest {
|
||||
return {
|
||||
_id: this._id,
|
||||
uid: this.uid,
|
||||
cover: this.cover,
|
||||
title: this.title,
|
||||
album: this.album,
|
||||
artist: this.artist,
|
||||
|
@ -3,11 +3,11 @@ import { Core } from "@ragestudio/vessel"
|
||||
import ActivityEvent from "@classes/ActivityEvent"
|
||||
import QueueManager from "@classes/QueueManager"
|
||||
import TrackInstance from "./classes/TrackInstance"
|
||||
//import MediaSession from "./classes/MediaSession"
|
||||
import MediaSession from "./classes/MediaSession"
|
||||
import ServiceProviders from "./classes/Services"
|
||||
import PlayerState from "./classes/PlayerState"
|
||||
import PlayerUI from "./classes/PlayerUI"
|
||||
import PlayerProcessors from "./classes/PlayerProcessors"
|
||||
import AudioBase from "./classes/AudioBase"
|
||||
|
||||
import setSampleRate from "./helpers/setSampleRate"
|
||||
|
||||
@ -22,27 +22,18 @@ export default class Player extends Core {
|
||||
|
||||
// player config
|
||||
static defaultSampleRate = 48000
|
||||
static gradualFadeMs = 150
|
||||
static maxManifestPrecompute = 3
|
||||
|
||||
state = new PlayerState(this)
|
||||
ui = new PlayerUI(this)
|
||||
serviceProviders = new ServiceProviders()
|
||||
//nativeControls = new MediaSession()
|
||||
audioContext = new AudioContext({
|
||||
sampleRate:
|
||||
AudioPlayerStorage.get("sample_rate") ?? Player.defaultSampleRate,
|
||||
latencyHint: "playback",
|
||||
})
|
||||
nativeControls = new MediaSession(this)
|
||||
|
||||
audioProcessors = new PlayerProcessors(this)
|
||||
base = new AudioBase(this)
|
||||
|
||||
queue = new QueueManager({
|
||||
loadFunction: this.createInstance,
|
||||
})
|
||||
|
||||
currentTrackInstance = null
|
||||
|
||||
public = {
|
||||
start: this.start,
|
||||
close: this.close,
|
||||
@ -74,10 +65,11 @@ export default class Player extends Core {
|
||||
eventBus: () => {
|
||||
return this.eventBus
|
||||
},
|
||||
base: () => {
|
||||
return this.base
|
||||
},
|
||||
state: this.state,
|
||||
ui: this.ui.public,
|
||||
audioContext: this.audioContext,
|
||||
gradualFadeMs: Player.gradualFadeMs,
|
||||
}
|
||||
|
||||
async afterInitialize() {
|
||||
@ -85,8 +77,8 @@ export default class Player extends Core {
|
||||
this.state.volume = 1
|
||||
}
|
||||
|
||||
//await this.nativeControls.initialize()
|
||||
await this.audioProcessors.initialize()
|
||||
await this.nativeControls.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
|
||||
//
|
||||
@ -112,46 +100,21 @@ export default class Player extends Core {
|
||||
throw new Error("Audio instance is required")
|
||||
}
|
||||
|
||||
this.console.log("Initializing instance", instance)
|
||||
|
||||
// resume audio context if needed
|
||||
if (this.audioContext.state === "suspended") {
|
||||
this.audioContext.resume()
|
||||
if (this.base.context.state === "suspended") {
|
||||
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
|
||||
this.state.track_manifest = this.queue.currentItem.manifest
|
||||
|
||||
// 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,
|
||||
)
|
||||
this.state.track_manifest =
|
||||
this.queue.currentItem.manifest.toSeriableObject()
|
||||
|
||||
// play
|
||||
await this.queue.currentItem.audio.play()
|
||||
|
||||
this.console.log(`Playing track >`, this.queue.currentItem)
|
||||
//await this.queue.currentItem.audio.play()
|
||||
await this.queue.currentItem.play(params)
|
||||
|
||||
// update native controls
|
||||
//this.nativeControls.update(this.queue.currentItem.manifest)
|
||||
this.nativeControls.update(this.queue.currentItem.manifest)
|
||||
|
||||
return this.queue.currentItem
|
||||
}
|
||||
@ -160,10 +123,10 @@ export default class Player extends Core {
|
||||
this.ui.attachPlayerComponent()
|
||||
|
||||
if (this.queue.currentItem) {
|
||||
await this.queue.currentItem.stop()
|
||||
await this.queue.currentItem.pause()
|
||||
}
|
||||
|
||||
await this.abortPreloads()
|
||||
//await this.abortPreloads()
|
||||
await this.queue.flush()
|
||||
|
||||
this.state.loading = true
|
||||
@ -187,8 +150,8 @@ export default class Player extends Core {
|
||||
playlist = await this.serviceProviders.resolveMany(playlist)
|
||||
}
|
||||
|
||||
for await (const [index, _manifest] of playlist.entries()) {
|
||||
let instance = await this.createInstance(_manifest)
|
||||
for await (let [index, _manifest] of playlist.entries()) {
|
||||
let instance = new TrackInstance(_manifest, this)
|
||||
|
||||
this.queue.add(instance)
|
||||
}
|
||||
@ -229,10 +192,6 @@ export default class Player extends Core {
|
||||
}
|
||||
|
||||
next() {
|
||||
if (this.queue.currentItem) {
|
||||
this.queue.currentItem.stop()
|
||||
}
|
||||
|
||||
//const isRandom = this.state.playback_mode === "shuffle"
|
||||
const item = this.queue.next()
|
||||
|
||||
@ -244,10 +203,6 @@ export default class Player extends Core {
|
||||
}
|
||||
|
||||
previous() {
|
||||
if (this.queue.currentItem) {
|
||||
this.queue.currentItem.stop()
|
||||
}
|
||||
|
||||
const item = this.queue.previous()
|
||||
|
||||
return this.play(item)
|
||||
@ -275,18 +230,14 @@ export default class Player extends Core {
|
||||
return null
|
||||
}
|
||||
|
||||
// set gain exponentially
|
||||
this.queue.currentItem.gainNode.gain.linearRampToValueAtTime(
|
||||
0.0001,
|
||||
this.audioContext.currentTime + Player.gradualFadeMs / 1000,
|
||||
)
|
||||
this.base.processors.gain.fade(0)
|
||||
|
||||
setTimeout(() => {
|
||||
this.queue.currentItem.audio.pause()
|
||||
this.queue.currentItem.pause()
|
||||
resolve()
|
||||
}, 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
|
||||
this.queue.currentItem.gainNode.gain.value = 0.0001
|
||||
|
||||
this.queue.currentItem.audio.play().then(() => {
|
||||
this.queue.currentItem.resume().then(() => {
|
||||
resolve()
|
||||
})
|
||||
this.base.processors.gain.fade(this.state.volume)
|
||||
|
||||
// set gain exponentially
|
||||
this.queue.currentItem.gainNode.gain.linearRampToValueAtTime(
|
||||
Math.pow(this.state.volume, 2),
|
||||
this.audioContext.currentTime + Player.gradualFadeMs / 1000,
|
||||
)
|
||||
|
||||
//this.nativeControls.updateIsPlaying(true)
|
||||
this.nativeControls.updateIsPlaying(true)
|
||||
})
|
||||
}
|
||||
|
||||
@ -325,10 +269,7 @@ export default class Player extends Core {
|
||||
|
||||
this.state.playback_mode = mode
|
||||
|
||||
if (this.queue.currentItem) {
|
||||
this.queue.currentItem.audio.loop =
|
||||
this.state.playback_mode === "repeat"
|
||||
}
|
||||
this.base.audio.loop = this.state.playback_mode === "repeat"
|
||||
|
||||
AudioPlayerStorage.set("mode", mode)
|
||||
|
||||
@ -336,22 +277,15 @@ export default class Player extends Core {
|
||||
}
|
||||
|
||||
stopPlayback() {
|
||||
if (this.queue.currentItem) {
|
||||
this.queue.currentItem.stop()
|
||||
}
|
||||
|
||||
this.base.flush()
|
||||
this.queue.flush()
|
||||
|
||||
this.abortPreloads()
|
||||
|
||||
this.state.playback_status = "stopped"
|
||||
this.state.track_manifest = 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") {
|
||||
this.state.muted = to
|
||||
this.queue.currentItem.audio.muted = to
|
||||
this.base.audio.muted = to
|
||||
}
|
||||
|
||||
return this.state.muted
|
||||
@ -395,65 +329,42 @@ export default class Player extends Core {
|
||||
volume = 0
|
||||
}
|
||||
|
||||
this.state.volume = volume
|
||||
|
||||
AudioPlayerStorage.set("volume", volume)
|
||||
|
||||
if (this.queue.currentItem) {
|
||||
if (this.queue.currentItem.gainNode) {
|
||||
this.queue.currentItem.gainNode.gain.value = Math.pow(
|
||||
this.state.volume,
|
||||
2,
|
||||
)
|
||||
}
|
||||
}
|
||||
this.state.volume = volume
|
||||
this.base.processors.gain.set(volume)
|
||||
|
||||
return this.state.volume
|
||||
}
|
||||
|
||||
seek(time) {
|
||||
if (!this.queue.currentItem || !this.queue.currentItem.audio) {
|
||||
if (!this.base.audio) {
|
||||
return false
|
||||
}
|
||||
|
||||
// if time not provided, return current time
|
||||
if (typeof time === "undefined") {
|
||||
return this.queue.currentItem.audio.currentTime
|
||||
return this.base.audio.currentTime
|
||||
}
|
||||
|
||||
// if time is provided, seek to that time
|
||||
if (typeof time === "number") {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
duration() {
|
||||
if (!this.queue.currentItem || !this.queue.currentItem.audio) {
|
||||
if (!this.base.audio) {
|
||||
return false
|
||||
}
|
||||
|
||||
return this.queue.currentItem.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
|
||||
return this.base.audio.duration
|
||||
}
|
||||
|
||||
close() {
|
||||
|
@ -2,44 +2,40 @@ import ProcessorNode from "../node"
|
||||
import Presets from "../../classes/Presets"
|
||||
|
||||
export default class CompressorProcessorNode extends ProcessorNode {
|
||||
constructor(props) {
|
||||
super(props)
|
||||
constructor(props) {
|
||||
super(props)
|
||||
|
||||
this.presets = new Presets({
|
||||
storage_key: "compressor",
|
||||
defaultPresetValue: {
|
||||
threshold: -50,
|
||||
knee: 40,
|
||||
ratio: 12,
|
||||
attack: 0.003,
|
||||
release: 0.25,
|
||||
},
|
||||
onApplyValues: this.applyValues.bind(this),
|
||||
})
|
||||
this.presets = new Presets({
|
||||
storage_key: "compressor",
|
||||
defaultPresetValue: {
|
||||
threshold: -50,
|
||||
knee: 40,
|
||||
ratio: 12,
|
||||
attack: 0.003,
|
||||
release: 0.25,
|
||||
},
|
||||
onApplyValues: this.applyValues.bind(this),
|
||||
})
|
||||
|
||||
this.exposeToPublic = {
|
||||
presets: this.presets,
|
||||
detach: this._detach,
|
||||
attach: this._attach,
|
||||
}
|
||||
}
|
||||
this.exposeToPublic = {
|
||||
presets: this.presets,
|
||||
detach: this._detach,
|
||||
attach: this._attach,
|
||||
}
|
||||
}
|
||||
|
||||
static refName = "compressor"
|
||||
static dependsOnSettings = ["player.compressor"]
|
||||
static refName = "compressor"
|
||||
static dependsOnSettings = ["player.compressor"]
|
||||
|
||||
async init(AudioContext) {
|
||||
if (!AudioContext) {
|
||||
throw new Error("AudioContext is required")
|
||||
}
|
||||
async init() {
|
||||
this.processor = this.audioContext.createDynamicsCompressor()
|
||||
|
||||
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]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -2,93 +2,98 @@ import ProcessorNode from "../node"
|
||||
import Presets from "../../classes/Presets"
|
||||
|
||||
export default class EqProcessorNode extends ProcessorNode {
|
||||
constructor(props) {
|
||||
super(props)
|
||||
constructor(props) {
|
||||
super(props)
|
||||
|
||||
this.presets = new Presets({
|
||||
storage_key: "eq",
|
||||
defaultPresetValue: {
|
||||
32: 0,
|
||||
64: 0,
|
||||
125: 0,
|
||||
250: 0,
|
||||
500: 0,
|
||||
1000: 0,
|
||||
2000: 0,
|
||||
4000: 0,
|
||||
8000: 0,
|
||||
16000: 0,
|
||||
},
|
||||
onApplyValues: this.applyValues.bind(this),
|
||||
})
|
||||
this.presets = new Presets({
|
||||
storage_key: "eq",
|
||||
defaultPresetValue: {
|
||||
32: 0,
|
||||
64: 0,
|
||||
125: 0,
|
||||
250: 0,
|
||||
500: 0,
|
||||
1000: 0,
|
||||
2000: 0,
|
||||
4000: 0,
|
||||
8000: 0,
|
||||
16000: 0,
|
||||
},
|
||||
onApplyValues: this.applyValues.bind(this),
|
||||
})
|
||||
|
||||
this.exposeToPublic = {
|
||||
presets: this.presets,
|
||||
}
|
||||
}
|
||||
this.exposeToPublic = {
|
||||
presets: this.presets,
|
||||
}
|
||||
}
|
||||
|
||||
static refName = "eq"
|
||||
static lock = true
|
||||
static refName = "eq"
|
||||
|
||||
applyValues() {
|
||||
// apply to current instance
|
||||
this.processor.eqNodes.forEach((processor) => {
|
||||
const gainValue = this.presets.currentPresetValues[processor.frequency.value]
|
||||
applyValues() {
|
||||
// apply to current instance
|
||||
this.processor.eqNodes.forEach((processor) => {
|
||||
const gainValue =
|
||||
this.presets.currentPresetValues[processor.frequency.value]
|
||||
|
||||
if (processor.gain.value !== gainValue) {
|
||||
console.debug(`[EQ] Applying values to ${processor.frequency.value} Hz frequency with gain ${gainValue}`)
|
||||
processor.gain.value = gainValue
|
||||
}
|
||||
})
|
||||
}
|
||||
if (processor.gain.value !== gainValue) {
|
||||
console.debug(
|
||||
`[EQ] Applying values to ${processor.frequency.value} Hz frequency with gain ${gainValue}`,
|
||||
)
|
||||
processor.gain.value = gainValue
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async init() {
|
||||
if (!this.audioContext) {
|
||||
throw new Error("audioContext is required")
|
||||
}
|
||||
async init() {
|
||||
if (!this.audioContext) {
|
||||
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) => {
|
||||
return {
|
||||
freq: parseFloat(entry[0]),
|
||||
gain: parseFloat(entry[1]),
|
||||
}
|
||||
})
|
||||
const values = Object.entries(this.presets.currentPresetValues).map(
|
||||
(entry) => {
|
||||
return {
|
||||
freq: parseFloat(entry[0]),
|
||||
gain: parseFloat(entry[1]),
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
values.forEach((eqValue, index) => {
|
||||
// chekc if freq and gain is valid
|
||||
if (isNaN(eqValue.freq)) {
|
||||
eqValue.freq = 0
|
||||
}
|
||||
if (isNaN(eqValue.gain)) {
|
||||
eqValue.gain = 0
|
||||
}
|
||||
values.forEach((eqValue, index) => {
|
||||
// chekc if freq and gain is valid
|
||||
if (isNaN(eqValue.freq)) {
|
||||
eqValue.freq = 0
|
||||
}
|
||||
if (isNaN(eqValue.gain)) {
|
||||
eqValue.gain = 0
|
||||
}
|
||||
|
||||
this.processor.eqNodes[index] = this.audioContext.createBiquadFilter()
|
||||
this.processor.eqNodes[index].type = "peaking"
|
||||
this.processor.eqNodes[index].frequency.value = eqValue.freq
|
||||
this.processor.eqNodes[index].gain.value = eqValue.gain
|
||||
})
|
||||
this.processor.eqNodes[index] =
|
||||
this.audioContext.createBiquadFilter()
|
||||
this.processor.eqNodes[index].type = "peaking"
|
||||
this.processor.eqNodes[index].frequency.value = eqValue.freq
|
||||
this.processor.eqNodes[index].gain.value = eqValue.gain
|
||||
})
|
||||
|
||||
// connect nodes
|
||||
for await (let [index, eqNode] of this.processor.eqNodes.entries()) {
|
||||
const nextNode = this.processor.eqNodes[index + 1]
|
||||
// connect nodes
|
||||
for await (let [index, eqNode] of this.processor.eqNodes.entries()) {
|
||||
const nextNode = this.processor.eqNodes[index + 1]
|
||||
|
||||
if (index === 0) {
|
||||
this.processor.connect(eqNode)
|
||||
}
|
||||
if (index === 0) {
|
||||
this.processor.connect(eqNode)
|
||||
}
|
||||
|
||||
if (nextNode) {
|
||||
eqNode.connect(nextNode)
|
||||
}
|
||||
}
|
||||
if (nextNode) {
|
||||
eqNode.connect(nextNode)
|
||||
}
|
||||
}
|
||||
|
||||
// set last processor for processor node can properly connect to the next node
|
||||
this.processor._last = this.processor.eqNodes.at(-1)
|
||||
}
|
||||
}
|
||||
// set last processor for processor node can properly connect to the next node
|
||||
this.processor._last = this.processor.eqNodes.at(-1)
|
||||
}
|
||||
}
|
||||
|
@ -1,60 +1,49 @@
|
||||
import AudioPlayerStorage from "../../player.storage"
|
||||
import ProcessorNode from "../node"
|
||||
|
||||
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 = {
|
||||
gain: 1,
|
||||
}
|
||||
setGain(gain) {
|
||||
gain = this.processGainValue(gain)
|
||||
|
||||
state = {
|
||||
gain: AudioPlayerStorage.get("gain") ?? GainProcessorNode.defaultValues.gain,
|
||||
}
|
||||
return (this.processor.gain.value = gain)
|
||||
}
|
||||
|
||||
exposeToPublic = {
|
||||
modifyValues: function (values) {
|
||||
this.state = {
|
||||
...this.state,
|
||||
...values,
|
||||
}
|
||||
linearRampToValueAtTime(gain, time) {
|
||||
gain = this.processGainValue(gain)
|
||||
return this.processor.gain.linearRampToValueAtTime(gain, time)
|
||||
}
|
||||
|
||||
AudioPlayerStorage.set("gain", this.state.gain)
|
||||
fade(gain) {
|
||||
if (gain <= 0) {
|
||||
gain = 0.0001
|
||||
} else {
|
||||
gain = this.processGainValue(gain)
|
||||
}
|
||||
|
||||
this.applyValues()
|
||||
}.bind(this),
|
||||
resetDefaultValues: function () {
|
||||
this.exposeToPublic.modifyValues(GainProcessorNode.defaultValues)
|
||||
const currentTime = this.audioContext.currentTime
|
||||
const fadeTime = currentTime + this.constructor.gradualFadeMs / 1000
|
||||
|
||||
return this.state
|
||||
}.bind(this),
|
||||
values: () => this.state,
|
||||
}
|
||||
this.processor.gain.linearRampToValueAtTime(gain, fadeTime)
|
||||
}
|
||||
|
||||
applyValues() {
|
||||
// apply to current instance
|
||||
this.processor.gain.value = app.cores.player.state.volume * this.state.gain
|
||||
}
|
||||
processGainValue(gain) {
|
||||
return Math.pow(gain, 2)
|
||||
}
|
||||
|
||||
async init() {
|
||||
if (!this.audioContext) {
|
||||
throw new Error("audioContext is required")
|
||||
}
|
||||
async init() {
|
||||
if (!this.audioContext) {
|
||||
throw new Error("audioContext is required")
|
||||
}
|
||||
|
||||
this.processor = this.audioContext.createGain()
|
||||
|
||||
this.applyValues()
|
||||
}
|
||||
|
||||
mutateInstance(instance) {
|
||||
if (!instance) {
|
||||
throw new Error("instance is required")
|
||||
}
|
||||
|
||||
instance.gainNode = this.processor
|
||||
|
||||
return instance
|
||||
}
|
||||
}
|
||||
this.processor = this.audioContext.createGain()
|
||||
this.processor.gain.value = this.player.state.volume
|
||||
}
|
||||
}
|
||||
|
@ -2,13 +2,12 @@ import EqProcessorNode from "./eqNode"
|
||||
import GainProcessorNode from "./gainNode"
|
||||
import CompressorProcessorNode from "./compressorNode"
|
||||
//import BPMProcessorNode from "./bpmNode"
|
||||
|
||||
import SpatialNode from "./spatialNode"
|
||||
//import SpatialNode from "./spatialNode"
|
||||
|
||||
export default [
|
||||
//BPMProcessorNode,
|
||||
EqProcessorNode,
|
||||
GainProcessorNode,
|
||||
CompressorProcessorNode,
|
||||
SpatialNode,
|
||||
]
|
||||
//BPMProcessorNode,
|
||||
EqProcessorNode,
|
||||
GainProcessorNode,
|
||||
CompressorProcessorNode,
|
||||
//SpatialNode,
|
||||
]
|
||||
|
@ -1,172 +1,147 @@
|
||||
export default class ProcessorNode {
|
||||
constructor(PlayerCore) {
|
||||
if (!PlayerCore) {
|
||||
throw new Error("PlayerCore is required")
|
||||
}
|
||||
constructor(manager) {
|
||||
if (!manager) {
|
||||
throw new Error("processorManager is required")
|
||||
}
|
||||
|
||||
this.PlayerCore = PlayerCore
|
||||
this.audioContext = PlayerCore.audioContext
|
||||
}
|
||||
this.manager = manager
|
||||
this.audioContext = manager.base.context
|
||||
this.elementSource = manager.base.elementSource
|
||||
this.player = manager.base.player
|
||||
}
|
||||
|
||||
async _init() {
|
||||
// check if has init method
|
||||
if (typeof this.init === "function") {
|
||||
await this.init(this.audioContext)
|
||||
}
|
||||
async _init() {
|
||||
// check if has init method
|
||||
if (typeof this.init === "function") {
|
||||
await this.init()
|
||||
}
|
||||
|
||||
// check if has declared bus events
|
||||
if (typeof this.busEvents === "object") {
|
||||
Object.entries(this.busEvents).forEach((event, fn) => {
|
||||
app.eventBus.on(event, fn)
|
||||
})
|
||||
}
|
||||
// check if has declared bus events
|
||||
if (typeof this.busEvents === "object") {
|
||||
Object.entries(this.busEvents).forEach((event, fn) => {
|
||||
app.eventBus.on(event, fn)
|
||||
})
|
||||
}
|
||||
|
||||
if (typeof this.processor._last === "undefined") {
|
||||
this.processor._last = this.processor
|
||||
}
|
||||
if (typeof this.processor._last === "undefined") {
|
||||
this.processor._last = this.processor
|
||||
}
|
||||
|
||||
return this
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
_attach(instance, index) {
|
||||
if (typeof instance !== "object") {
|
||||
instance = this.PlayerCore.currentAudioInstance
|
||||
}
|
||||
_attach(index) {
|
||||
// check if has dependsOnSettings
|
||||
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
|
||||
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 null
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
if (!index) {
|
||||
index = instance.attachedProcessors.length
|
||||
}
|
||||
const prevNode = this.manager.attached[index - 1]
|
||||
const nextNode = this.manager.attached[index + 1]
|
||||
|
||||
const prevNode = instance.attachedProcessors[index - 1]
|
||||
const nextNode = instance.attachedProcessors[index + 1]
|
||||
const currentIndex = this._findIndex()
|
||||
|
||||
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
|
||||
if (currentIndex !== false) {
|
||||
console.warn(`[${this.constructor.refName ?? this.constructor.name}] node is already attached`)
|
||||
return null
|
||||
}
|
||||
|
||||
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
|
||||
// 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()
|
||||
// now, connect to the processor
|
||||
prevNode.processor._last.connect(this.processor)
|
||||
} else {
|
||||
//console.log(`[${this.constructor.refName ?? this.constructor.name}] node is the first node, connecting to the media source...`)
|
||||
this.elementSource.connect(this.processor)
|
||||
}
|
||||
|
||||
// now, connect to the processor
|
||||
prevNode.processor._last.connect(this.processor)
|
||||
} else {
|
||||
//console.log(`[${this.constructor.refName ?? this.constructor.name}] node is the first node, connecting to the media source...`)
|
||||
instance.contextElement.connect(this.processor)
|
||||
}
|
||||
// now, check if it has a next node
|
||||
// if has, connect to it
|
||||
// if not, connect to the destination
|
||||
if (nextNode) {
|
||||
this.processor.connect(nextNode.processor)
|
||||
}
|
||||
|
||||
// now, check if it has a next node
|
||||
// if has, connect to it
|
||||
// if not, connect to the destination
|
||||
if (nextNode) {
|
||||
this.processor.connect(nextNode.processor)
|
||||
}
|
||||
// add to the attachedProcessors
|
||||
this.manager.attached.splice(index, 0, this)
|
||||
|
||||
// add to the attachedProcessors
|
||||
instance.attachedProcessors.splice(index, 0, this)
|
||||
// // handle instance mutation
|
||||
// if (typeof this.mutateInstance === "function") {
|
||||
// instance = this.mutateInstance(instance)
|
||||
// }
|
||||
|
||||
// handle instance mutation
|
||||
if (typeof this.mutateInstance === "function") {
|
||||
instance = this.mutateInstance(instance)
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
return instance
|
||||
}
|
||||
_detach() {
|
||||
// find index of the node within the attachedProcessors serching for matching refName
|
||||
const index = this._findIndex()
|
||||
|
||||
_detach(instance) {
|
||||
if (typeof instance !== "object") {
|
||||
instance = this.PlayerCore.currentAudioInstance
|
||||
}
|
||||
if (!index) {
|
||||
return null
|
||||
}
|
||||
|
||||
// find index of the node within the attachedProcessors serching for matching refName
|
||||
const index = this._findIndex(instance)
|
||||
// retrieve the previous and next nodes
|
||||
const prevNode = this.manager.attached[index - 1]
|
||||
const nextNode = this.manager.attached[index + 1]
|
||||
|
||||
if (!index) {
|
||||
return instance
|
||||
}
|
||||
// check if has previous node and if has outputs
|
||||
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
|
||||
const prevNode = instance.attachedProcessors[index - 1]
|
||||
const nextNode = instance.attachedProcessors[index + 1]
|
||||
// disconnect
|
||||
this.processor.disconnect()
|
||||
this.manager.attached.splice(index, 1)
|
||||
|
||||
// check if has previous node and if has outputs
|
||||
if (prevNode && prevNode.processor._last.numberOfOutputs > 0) {
|
||||
// if has outputs, disconnect from the previous node
|
||||
prevNode.processor._last.disconnect()
|
||||
}
|
||||
// now, connect the previous node to the next node
|
||||
if (prevNode && nextNode) {
|
||||
prevNode.processor._last.connect(nextNode.processor)
|
||||
} else {
|
||||
// it means that this is the last node, so connect to the destination
|
||||
prevNode.processor._last.connect(this.audioContext.destination)
|
||||
}
|
||||
|
||||
// disconnect
|
||||
instance = this._destroy(instance)
|
||||
return this
|
||||
}
|
||||
|
||||
// now, connect the previous node to the next node
|
||||
if (prevNode && nextNode) {
|
||||
prevNode.processor._last.connect(nextNode.processor)
|
||||
} else {
|
||||
// it means that this is the last node, so connect to the destination
|
||||
prevNode.processor._last.connect(this.audioContext.destination)
|
||||
}
|
||||
_findIndex() {
|
||||
// find index of the node within the attachedProcessors serching for matching refName
|
||||
const index = this.manager.attached.findIndex((node) => {
|
||||
return node.constructor.refName === this.constructor.refName
|
||||
})
|
||||
|
||||
return instance
|
||||
}
|
||||
if (index === -1) {
|
||||
return false
|
||||
}
|
||||
|
||||
_destroy(instance) {
|
||||
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
|
||||
}
|
||||
}
|
||||
return index
|
||||
}
|
||||
}
|
||||
|
@ -84,9 +84,9 @@ export default class RemoteStorage extends Core {
|
||||
_reject(message)
|
||||
})
|
||||
|
||||
uploader.events.on("progress", ({ percentProgress }) => {
|
||||
uploader.events.on("progress", (data) => {
|
||||
if (typeof onProgress === "function") {
|
||||
onProgress(file, percentProgress)
|
||||
onProgress(file, data)
|
||||
}
|
||||
})
|
||||
|
||||
|
@ -6,102 +6,119 @@ import store from "store"
|
||||
import config from "@config"
|
||||
|
||||
export default class SFXCore extends Core {
|
||||
static namespace = "sfx"
|
||||
static namespace = "sfx"
|
||||
|
||||
soundsPool = {}
|
||||
soundsPool = {}
|
||||
|
||||
public = {
|
||||
loadSoundpack: this.loadSoundpack.bind(this),
|
||||
play: this.play,
|
||||
}
|
||||
public = {
|
||||
loadSoundpack: this.loadSoundpack.bind(this),
|
||||
play: this.play,
|
||||
}
|
||||
|
||||
onEvents = {
|
||||
"sfx:test": (volume) => {
|
||||
// play a sound to test volume
|
||||
this.play("test", {
|
||||
volume: volume / 100,
|
||||
})
|
||||
}
|
||||
}
|
||||
onEvents = {
|
||||
"sfx:test": (volume) => {
|
||||
// play a sound to test volume
|
||||
this.play("test", {
|
||||
volume: volume / 100,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
async loadSoundpack(soundpack) {
|
||||
if (!soundpack) {
|
||||
soundpack = store.get("soundpack")
|
||||
}
|
||||
async loadSoundpack(soundpack) {
|
||||
if (!soundpack) {
|
||||
soundpack = store.get("soundpack")
|
||||
}
|
||||
|
||||
if (!soundpack) {
|
||||
soundpack = config.defaultSoundPack ?? {}
|
||||
}
|
||||
if (!soundpack) {
|
||||
soundpack = config.defaultSoundPack ?? {}
|
||||
}
|
||||
|
||||
// check if is valid url with regex
|
||||
const urlRegex = /^(http|https):\/\/[^ "]+$/;
|
||||
// check if is valid url with regex
|
||||
const urlRegex = /^(http|https):\/\/[^ "]+$/
|
||||
|
||||
if (urlRegex.test(soundpack)) {
|
||||
const { data } = await axios.get(soundpack)
|
||||
if (urlRegex.test(soundpack)) {
|
||||
const { data } = await axios.get(soundpack)
|
||||
|
||||
soundpack = data
|
||||
}
|
||||
soundpack = data
|
||||
}
|
||||
|
||||
if (typeof soundpack.sounds !== "object") {
|
||||
this.console.error(`Soundpack [${soundpack.id}] is not a valid soundpack.`)
|
||||
return false
|
||||
}
|
||||
if (typeof soundpack.sounds !== "object") {
|
||||
this.console.error(
|
||||
`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)) {
|
||||
this.soundsPool[name] = new Howl({
|
||||
volume: 0.5,
|
||||
src: [path],
|
||||
})
|
||||
}
|
||||
}
|
||||
for (const [name, path] of Object.entries(soundpack.sounds)) {
|
||||
this.soundsPool[name] = new Howl({
|
||||
volume: 0.5,
|
||||
src: [path],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async play(name, options = {}) {
|
||||
if (!window.app.cores.settings.is("ui.effects", true)) {
|
||||
return false
|
||||
}
|
||||
async play(name, options = {}) {
|
||||
if (!window.app.cores.settings.is("ui.effects", true)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const audioInstance = this.soundsPool[name]
|
||||
const audioInstance = this.soundsPool[name]
|
||||
|
||||
if (!audioInstance) {
|
||||
return false
|
||||
}
|
||||
if (!audioInstance) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (typeof options.volume !== "undefined") {
|
||||
audioInstance.volume(options.volume)
|
||||
} else {
|
||||
audioInstance.volume((window.app.cores.settings.get("ui.general_volume") ?? 0) / 100)
|
||||
}
|
||||
if (typeof options.volume !== "undefined") {
|
||||
audioInstance.volume(options.volume)
|
||||
} else {
|
||||
audioInstance.volume(
|
||||
(window.app.cores.settings.get("ui.general_volume") ?? 0) / 100,
|
||||
)
|
||||
}
|
||||
|
||||
audioInstance.play()
|
||||
}
|
||||
audioInstance.play()
|
||||
}
|
||||
|
||||
async handleClick(event) {
|
||||
// search for closest button
|
||||
const button = event.target.closest("button") || event.target.closest(".ant-btn")
|
||||
async handleClick(event) {
|
||||
// search for closest button
|
||||
const button =
|
||||
event.target.closest("button") || event.target.closest(".ant-btn")
|
||||
|
||||
// search for a slider
|
||||
const slider = event.target.closest("input[type=range]")
|
||||
// search for a slider
|
||||
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) {
|
||||
if (button.hasAttribute("aria-checked")) {
|
||||
return this.play(button.getAttribute("aria-checked") === "true" ? "component.switch_off" : "component.switch_on")
|
||||
}
|
||||
// if button exist and has aria-checked attribute then play switch_on or switch_off
|
||||
if (button) {
|
||||
if (button.hasAttribute("aria-checked")) {
|
||||
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) {
|
||||
// check if is up or down
|
||||
this.console.log(slider)
|
||||
}
|
||||
}
|
||||
if (slider) {
|
||||
// check if is up or down
|
||||
this.console.log(slider)
|
||||
}
|
||||
}
|
||||
|
||||
async onInitialize() {
|
||||
await this.loadSoundpack()
|
||||
async onInitialize() {
|
||||
await this.loadSoundpack()
|
||||
|
||||
document.addEventListener("click", (...args) => { this.handleClick(...args) }, true)
|
||||
}
|
||||
}
|
||||
document.addEventListener(
|
||||
"click",
|
||||
(...args) => {
|
||||
this.handleClick(...args)
|
||||
},
|
||||
true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -4,7 +4,7 @@ import classnames from "classnames"
|
||||
import { Icons } from "@components/Icons"
|
||||
import SeekBar from "@components/Player/SeekBar"
|
||||
import Controls from "@components/Player/Controls"
|
||||
import ExtraActions from "@components/Player/ExtraActions"
|
||||
import Actions from "@components/Player/Actions"
|
||||
|
||||
import { usePlayerStateContext } from "@contexts/WithPlayerContext"
|
||||
import RGBStringToValues from "@utils/rgbToValues"
|
||||
@ -12,102 +12,96 @@ import RGBStringToValues from "@utils/rgbToValues"
|
||||
import "./index.less"
|
||||
|
||||
const ServiceIndicator = (props) => {
|
||||
if (!props.service) {
|
||||
return null
|
||||
}
|
||||
if (!props.service) {
|
||||
return null
|
||||
}
|
||||
|
||||
switch (props.service) {
|
||||
case "tidal": {
|
||||
return <div className="service_indicator">
|
||||
<Icons.SiTidal /> Playing from Tidal
|
||||
</div>
|
||||
}
|
||||
default: {
|
||||
return null
|
||||
}
|
||||
}
|
||||
switch (props.service) {
|
||||
case "tidal": {
|
||||
return (
|
||||
<div className="service_indicator">
|
||||
<Icons.SiTidal /> Playing from Tidal
|
||||
</div>
|
||||
)
|
||||
}
|
||||
default: {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const AudioPlayer = (props) => {
|
||||
const [playerState] = usePlayerStateContext()
|
||||
const [playerState] = usePlayerStateContext()
|
||||
|
||||
React.useEffect(() => {
|
||||
if (app.currentDragger) {
|
||||
app.currentDragger.setBackgroundColorValues(RGBStringToValues(playerState.track_manifest?.cover_analysis?.rgb))
|
||||
}
|
||||
React.useEffect(() => {
|
||||
if (app.currentDragger) {
|
||||
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 {
|
||||
title,
|
||||
album,
|
||||
artist,
|
||||
service,
|
||||
lyricsEnabled,
|
||||
cover_analysis,
|
||||
cover,
|
||||
} = playerState.track_manifest ?? {}
|
||||
const playing = playerState.playback_status === "playing"
|
||||
const stopped = playerState.playback_status === "stopped"
|
||||
|
||||
const playing = playerState.playback_status === "playing"
|
||||
const stopped = playerState.playback_status === "stopped"
|
||||
const titleText = !playing && stopped ? "Stopped" : (title ?? "Untitled")
|
||||
const subtitleText = `${artist} | ${album?.title ?? album}`
|
||||
|
||||
const titleText = (!playing && stopped) ? "Stopped" : (title ?? "Untitled")
|
||||
const subtitleText = `${artist} | ${album?.title ?? album}`
|
||||
return (
|
||||
<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
|
||||
className={classnames(
|
||||
"mobile_media_player_wrapper",
|
||||
{
|
||||
"cover_light": cover_analysis?.isLight,
|
||||
}
|
||||
)}
|
||||
style={{
|
||||
"--cover_isLight": cover_analysis?.isLight,
|
||||
}}
|
||||
>
|
||||
<div className="mobile_media_player">
|
||||
<ServiceIndicator
|
||||
service={service}
|
||||
/>
|
||||
<div
|
||||
className="mobile-player-cover"
|
||||
style={{
|
||||
backgroundImage: `url(${cover ?? "/assets/no_song.png"})`,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="cover"
|
||||
style={{
|
||||
backgroundImage: `url(${cover ?? "/assets/no_song.png"})`,
|
||||
}}
|
||||
/>
|
||||
<div className="mobile-player-header">
|
||||
<div className="mobile-player-info">
|
||||
<div className="mobile-player-info-title">
|
||||
<h1>{titleText}</h1>
|
||||
</div>
|
||||
<div className="mobile-player-info-subTitle">
|
||||
<span>{subtitleText}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="header">
|
||||
<div className="info">
|
||||
<div className="title">
|
||||
<h2>
|
||||
{
|
||||
titleText
|
||||
}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="subTitle">
|
||||
<div className="artist">
|
||||
<h3>
|
||||
{subtitleText}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Controls />
|
||||
|
||||
<Controls />
|
||||
<SeekBar
|
||||
stopped={playerState.playback_status === "stopped"}
|
||||
playing={playerState.playback_status === "playing"}
|
||||
streamMode={playerState.livestream_mode}
|
||||
disabled={playerState.control_locked}
|
||||
/>
|
||||
|
||||
<SeekBar
|
||||
stopped={playerState.playback_status === "stopped"}
|
||||
playing={playerState.playback_status === "playing"}
|
||||
streamMode={playerState.livestream_mode}
|
||||
disabled={playerState.control_locked}
|
||||
/>
|
||||
|
||||
<ExtraActions />
|
||||
</div>
|
||||
</div>
|
||||
<Actions />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AudioPlayer
|
||||
export default AudioPlayer
|
||||
|
@ -1,199 +1,184 @@
|
||||
@top_controls_height: 55px;
|
||||
|
||||
.mobile_media_player_wrapper {
|
||||
position: relative;
|
||||
.mobile-player_wrapper {
|
||||
position: relative;
|
||||
|
||||
z-index: 320;
|
||||
z-index: 320;
|
||||
|
||||
display: flex;
|
||||
display: flex;
|
||||
|
||||
flex-direction: column;
|
||||
flex-direction: column;
|
||||
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
.mobile_media_player_background {
|
||||
position: absolute;
|
||||
margin-bottom: 30px;
|
||||
|
||||
z-index: 320;
|
||||
.mobile-player_background {
|
||||
position: absolute;
|
||||
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 320;
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
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 {
|
||||
position: relative;
|
||||
.mobile-player {
|
||||
position: relative;
|
||||
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
width: 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 {
|
||||
color: var(--text-color);
|
||||
.service_indicator {
|
||||
color: var(--text-color);
|
||||
|
||||
background-color: var(--background-color-accent);
|
||||
padding: 7px;
|
||||
border-radius: 8px;
|
||||
background-color: var(--background-color-accent);
|
||||
padding: 7px;
|
||||
border-radius: 8px;
|
||||
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.cover {
|
||||
position: relative;
|
||||
.mobile-player-cover {
|
||||
position: relative;
|
||||
|
||||
z-index: 320;
|
||||
z-index: 320;
|
||||
|
||||
margin: auto;
|
||||
margin: auto;
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
min-height: 40vh;
|
||||
min-width: 100%;
|
||||
min-height: 40vh;
|
||||
min-width: 100%;
|
||||
|
||||
border-radius: 24px;
|
||||
border-radius: 24px;
|
||||
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
|
||||
transition: all 0.3s ease-in-out;
|
||||
transition: all 0.3s ease-in-out;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
}
|
||||
}
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
position: relative;
|
||||
.mobile-player-header {
|
||||
position: relative;
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
width: 100%;
|
||||
width: 100%;
|
||||
|
||||
.info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
.mobile-player-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6,
|
||||
p,
|
||||
span {
|
||||
margin: 0;
|
||||
color: var(--text-color);
|
||||
}
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6,
|
||||
p,
|
||||
span {
|
||||
margin: 0;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
width: 100%;
|
||||
width: 100%;
|
||||
|
||||
.title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
.mobile-player-info-title {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
word-break: break-all;
|
||||
align-items: center;
|
||||
|
||||
font-family: "Space Grotesk", sans-serif;
|
||||
}
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
|
||||
.subTitle {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
word-break: break-all;
|
||||
|
||||
width: 100%;
|
||||
font-family: "Space Grotesk", sans-serif;
|
||||
}
|
||||
|
||||
justify-content: space-between;
|
||||
.mobile-player-info-subTitle {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
.likeButton {
|
||||
margin-right: 20px;
|
||||
}
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.artist {
|
||||
font-size: 0.6rem;
|
||||
font-weight: 400;
|
||||
color: var(--text-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
font-size: 0.7rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.player-controls {
|
||||
.ant-btn {
|
||||
min-width: 40px !important;
|
||||
min-height: 40px !important;
|
||||
}
|
||||
.player-controls {
|
||||
.ant-btn {
|
||||
min-width: 40px !important;
|
||||
min-height: 40px !important;
|
||||
}
|
||||
|
||||
svg {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
svg {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.playButton {
|
||||
min-width: 50px !important;
|
||||
min-height: 50px !important;
|
||||
.playButton {
|
||||
min-width: 50px !important;
|
||||
min-height: 50px !important;
|
||||
|
||||
svg {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
svg {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.player-seek_bar {
|
||||
.progress {
|
||||
.MuiSlider-root {
|
||||
.MuiSlider-rail {
|
||||
height: 7px;
|
||||
}
|
||||
.player-seek_bar {
|
||||
.progress {
|
||||
.MuiSlider-root {
|
||||
.MuiSlider-rail {
|
||||
height: 7px;
|
||||
}
|
||||
|
||||
.MuiSlider-track {
|
||||
height: 7px;
|
||||
}
|
||||
.MuiSlider-track {
|
||||
height: 7px;
|
||||
}
|
||||
|
||||
.MuiSlider-thumb {
|
||||
width: 5px;
|
||||
height: 13px;
|
||||
border-radius: 2px;
|
||||
.MuiSlider-thumb {
|
||||
width: 5px;
|
||||
height: 13px;
|
||||
border-radius: 2px;
|
||||
|
||||
background-color: var(--background-color-contrast);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.extra_actions {
|
||||
padding: 0 30px;
|
||||
|
||||
.ant-btn {
|
||||
padding: 5px;
|
||||
|
||||
svg {
|
||||
height: 23px;
|
||||
min-width: 23px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
background-color: var(--background-color-contrast);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
47
packages/app/src/pages/_debug/audiometadata/index.jsx
Normal file
47
packages/app/src/pages/_debug/audiometadata/index.jsx
Normal 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
|
@ -177,7 +177,7 @@ const PlayerController = React.forwardRef((props, ref) => {
|
||||
)}
|
||||
|
||||
<div className="lyrics-player-controller-tags">
|
||||
{playerState.track_manifest?.metadata.lossless && (
|
||||
{playerState.track_manifest?.metadata?.lossless && (
|
||||
<Tag
|
||||
icon={
|
||||
<Icons.Lossless
|
||||
|
@ -7,178 +7,197 @@ import { usePlayerStateContext } from "@contexts/WithPlayerContext"
|
||||
const maxLatencyInMs = 55
|
||||
|
||||
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 [syncInterval, setSyncInterval] = React.useState(null)
|
||||
const [syncingVideo, setSyncingVideo] = React.useState(false)
|
||||
const [currentVideoLatency, setCurrentVideoLatency] = React.useState(0)
|
||||
const hls = React.useRef(new HLS())
|
||||
const [initialLoading, setInitialLoading] = React.useState(true)
|
||||
const [syncInterval, setSyncInterval] = React.useState(null)
|
||||
const [syncingVideo, setSyncingVideo] = React.useState(false)
|
||||
const [currentVideoLatency, setCurrentVideoLatency] = React.useState(0)
|
||||
const hls = React.useRef(new HLS())
|
||||
|
||||
async function seekVideoToSyncAudio() {
|
||||
if (!lyrics) {
|
||||
return null
|
||||
}
|
||||
async function seekVideoToSyncAudio() {
|
||||
if (!lyrics) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!lyrics.video_source || typeof lyrics.sync_audio_at_ms === "undefined") {
|
||||
return null
|
||||
}
|
||||
if (
|
||||
!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
|
||||
newTime -= 5 / 1000
|
||||
// dec some ms to ensure the video seeks correctly
|
||||
newTime -= 5 / 1000
|
||||
|
||||
videoRef.current.currentTime = newTime
|
||||
}
|
||||
videoRef.current.currentTime = newTime
|
||||
}
|
||||
|
||||
async function syncPlayback() {
|
||||
// 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") {
|
||||
return stopSyncInterval()
|
||||
}
|
||||
async function syncPlayback() {
|
||||
// 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"
|
||||
) {
|
||||
return stopSyncInterval()
|
||||
}
|
||||
|
||||
const currentTrackTime = app.cores.player.controls.seek()
|
||||
const currentVideoTime = videoRef.current.currentTime - (lyrics.sync_audio_at_ms / 1000)
|
||||
const currentTrackTime = app.cores.player.controls.seek()
|
||||
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 currentVideoTimeDiff = Math.abs(currentVideoTime - currentTrackTime)
|
||||
const maxOffset = maxLatencyInMs / 1000
|
||||
const currentVideoTimeDiff = Math.abs(
|
||||
currentVideoTime - currentTrackTime,
|
||||
)
|
||||
|
||||
setCurrentVideoLatency(currentVideoTimeDiff)
|
||||
setCurrentVideoLatency(currentVideoTimeDiff)
|
||||
|
||||
if (syncingVideo === true) {
|
||||
return false
|
||||
}
|
||||
if (syncingVideo === true) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (currentVideoTimeDiff > maxOffset) {
|
||||
seekVideoToSyncAudio()
|
||||
}
|
||||
}
|
||||
if (currentVideoTimeDiff > maxOffset) {
|
||||
seekVideoToSyncAudio()
|
||||
}
|
||||
}
|
||||
|
||||
function startSyncInterval() {
|
||||
setSyncInterval(setInterval(syncPlayback, 300))
|
||||
}
|
||||
function startSyncInterval() {
|
||||
setSyncInterval(setInterval(syncPlayback, 300))
|
||||
}
|
||||
|
||||
function stopSyncInterval() {
|
||||
setSyncingVideo(false)
|
||||
setSyncInterval(null)
|
||||
clearInterval(syncInterval)
|
||||
}
|
||||
function stopSyncInterval() {
|
||||
setSyncingVideo(false)
|
||||
setSyncInterval(null)
|
||||
clearInterval(syncInterval)
|
||||
}
|
||||
|
||||
//* handle when player is loading
|
||||
React.useEffect(() => {
|
||||
if (lyrics?.video_source && playerState.loading === true && playerState.playback_status === "playing") {
|
||||
videoRef.current.pause()
|
||||
}
|
||||
//* handle when player is loading
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
lyrics?.video_source &&
|
||||
playerState.loading === true &&
|
||||
playerState.playback_status === "playing"
|
||||
) {
|
||||
videoRef.current.pause()
|
||||
}
|
||||
|
||||
if (lyrics?.video_source && playerState.loading === false && playerState.playback_status === "playing") {
|
||||
videoRef.current.play()
|
||||
}
|
||||
}, [playerState.loading])
|
||||
if (
|
||||
lyrics?.video_source &&
|
||||
playerState.loading === false &&
|
||||
playerState.playback_status === "playing"
|
||||
) {
|
||||
videoRef.current.play()
|
||||
}
|
||||
}, [playerState.loading])
|
||||
|
||||
//* Handle when playback status change
|
||||
React.useEffect(() => {
|
||||
if (initialLoading === false) {
|
||||
console.log(`VIDEO:: Playback status changed to ${playerState.playback_status}`)
|
||||
//* Handle when playback status change
|
||||
React.useEffect(() => {
|
||||
if (initialLoading === false) {
|
||||
console.log(
|
||||
`VIDEO:: Playback status changed to ${playerState.playback_status}`,
|
||||
)
|
||||
|
||||
if (lyrics && lyrics.video_source) {
|
||||
if (playerState.playback_status === "playing") {
|
||||
videoRef.current.play()
|
||||
startSyncInterval()
|
||||
} else {
|
||||
videoRef.current.pause()
|
||||
stopSyncInterval()
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [playerState.playback_status])
|
||||
if (lyrics && lyrics.video_source) {
|
||||
if (playerState.playback_status === "playing") {
|
||||
videoRef.current.play()
|
||||
startSyncInterval()
|
||||
} else {
|
||||
videoRef.current.pause()
|
||||
stopSyncInterval()
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [playerState.playback_status])
|
||||
|
||||
//* Handle when lyrics object change
|
||||
React.useEffect(() => {
|
||||
setCurrentVideoLatency(0)
|
||||
stopSyncInterval()
|
||||
//* Handle when lyrics object change
|
||||
React.useEffect(() => {
|
||||
setCurrentVideoLatency(0)
|
||||
stopSyncInterval()
|
||||
|
||||
if (lyrics) {
|
||||
if (lyrics.video_source) {
|
||||
console.log("Loading video source >", lyrics.video_source)
|
||||
if (lyrics) {
|
||||
if (lyrics.video_source) {
|
||||
console.log("Loading video source >", lyrics.video_source)
|
||||
|
||||
if (lyrics.video_source.endsWith(".mp4")) {
|
||||
videoRef.current.src = lyrics.video_source
|
||||
} else {
|
||||
hls.current.loadSource(lyrics.video_source)
|
||||
}
|
||||
if (lyrics.video_source.endsWith(".mp4")) {
|
||||
videoRef.current.src = lyrics.video_source
|
||||
} else {
|
||||
hls.current.loadSource(lyrics.video_source)
|
||||
}
|
||||
|
||||
if (typeof lyrics.sync_audio_at_ms !== "undefined") {
|
||||
videoRef.current.loop = false
|
||||
videoRef.current.currentTime = lyrics.sync_audio_at_ms / 1000
|
||||
if (typeof lyrics.sync_audio_at_ms !== "undefined") {
|
||||
videoRef.current.loop = false
|
||||
videoRef.current.currentTime =
|
||||
lyrics.sync_audio_at_ms / 1000
|
||||
|
||||
startSyncInterval()
|
||||
} else {
|
||||
videoRef.current.loop = true
|
||||
videoRef.current.currentTime = 0
|
||||
}
|
||||
startSyncInterval()
|
||||
} else {
|
||||
videoRef.current.loop = true
|
||||
videoRef.current.currentTime = 0
|
||||
}
|
||||
|
||||
if (playerState.playback_status === "playing") {
|
||||
videoRef.current.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
if (playerState.playback_status === "playing") {
|
||||
videoRef.current.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setInitialLoading(false)
|
||||
}, [lyrics])
|
||||
setInitialLoading(false)
|
||||
}, [lyrics])
|
||||
|
||||
React.useEffect(() => {
|
||||
videoRef.current.addEventListener("seeked", (event) => {
|
||||
setSyncingVideo(false)
|
||||
})
|
||||
React.useEffect(() => {
|
||||
videoRef.current.addEventListener("seeked", (event) => {
|
||||
setSyncingVideo(false)
|
||||
})
|
||||
|
||||
hls.current.attachMedia(videoRef.current)
|
||||
hls.current.attachMedia(videoRef.current)
|
||||
|
||||
return () => {
|
||||
stopSyncInterval()
|
||||
}
|
||||
}, [])
|
||||
return () => {
|
||||
stopSyncInterval()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return <>
|
||||
{
|
||||
props.lyrics?.sync_audio_at && <div
|
||||
className={classnames(
|
||||
"videoDebugOverlay",
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<p>Maximun latency</p>
|
||||
<p>{maxLatencyInMs}ms</p>
|
||||
</div>
|
||||
<div>
|
||||
<p>Video Latency</p>
|
||||
<p>{(currentVideoLatency * 1000).toFixed(2)}ms</p>
|
||||
</div>
|
||||
{syncingVideo ? <p>Syncing video...</p> : null}
|
||||
</div>
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{props.lyrics?.sync_audio_at && (
|
||||
<div className={classnames("videoDebugOverlay")}>
|
||||
<div>
|
||||
<p>Maximun latency</p>
|
||||
<p>{maxLatencyInMs}ms</p>
|
||||
</div>
|
||||
<div>
|
||||
<p>Video Latency</p>
|
||||
<p>{(currentVideoLatency * 1000).toFixed(2)}ms</p>
|
||||
</div>
|
||||
{syncingVideo ? <p>Syncing video...</p> : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<video
|
||||
className={classnames(
|
||||
"lyrics-video",
|
||||
{
|
||||
["hidden"]: !lyrics || !lyrics?.video_source
|
||||
}
|
||||
)}
|
||||
ref={videoRef}
|
||||
controls={false}
|
||||
muted
|
||||
preload="auto"
|
||||
/>
|
||||
</>
|
||||
<video
|
||||
className={classnames("lyrics-video", {
|
||||
["hidden"]: !lyrics || !lyrics?.video_source,
|
||||
})}
|
||||
ref={videoRef}
|
||||
controls={false}
|
||||
muted
|
||||
preload="auto"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
export default LyricsVideo
|
||||
export default LyricsVideo
|
||||
|
@ -89,7 +89,8 @@ const EnhancedLyricsPage = () => {
|
||||
|
||||
// Track manifest comparison
|
||||
useEffect(() => {
|
||||
const newManifest = playerState.track_manifest?.toSeriableObject()
|
||||
const newManifest = playerState.track_manifest
|
||||
|
||||
if (JSON.stringify(newManifest) !== JSON.stringify(trackManifest)) {
|
||||
setTrackManifest(newManifest)
|
||||
}
|
||||
|
@ -11,33 +11,33 @@ export default {
|
||||
}
|
||||
},
|
||||
settings: [
|
||||
{
|
||||
id: "player.gain",
|
||||
title: "Gain",
|
||||
icon: "MdGraphicEq",
|
||||
group: "general",
|
||||
description: "Adjust gain for audio output",
|
||||
component: "Slider",
|
||||
props: {
|
||||
min: 1,
|
||||
max: 2,
|
||||
step: 0.1,
|
||||
marks: {
|
||||
1: "Normal",
|
||||
1.5: "+50%",
|
||||
2: "+100%",
|
||||
},
|
||||
},
|
||||
defaultValue: () => {
|
||||
return app.cores.player.gain.values().gain
|
||||
},
|
||||
onUpdate: (value) => {
|
||||
app.cores.player.gain.modifyValues({
|
||||
gain: value,
|
||||
})
|
||||
},
|
||||
storaged: false,
|
||||
},
|
||||
// {
|
||||
// id: "player.gain",
|
||||
// title: "Gain",
|
||||
// icon: "MdGraphicEq",
|
||||
// group: "general",
|
||||
// description: "Adjust gain for audio output",
|
||||
// component: "Slider",
|
||||
// props: {
|
||||
// min: 1,
|
||||
// max: 2,
|
||||
// step: 0.1,
|
||||
// marks: {
|
||||
// 1: "Normal",
|
||||
// 1.5: "+50%",
|
||||
// 2: "+100%",
|
||||
// },
|
||||
// },
|
||||
// defaultValue: () => {
|
||||
// return app.cores.player.gain.values().gain
|
||||
// },
|
||||
// onUpdate: (value) => {
|
||||
// app.cores.player.gain.modifyValues({
|
||||
// gain: value,
|
||||
// })
|
||||
// },
|
||||
// storaged: false,
|
||||
// },
|
||||
{
|
||||
id: "player.sample_rate",
|
||||
title: "Sample Rate",
|
||||
@ -66,7 +66,7 @@ export default {
|
||||
],
|
||||
},
|
||||
defaultValue: (ctx) => {
|
||||
return app.cores.player.audioContext.sampleRate
|
||||
return app.cores.player.base().context.sampleRate
|
||||
},
|
||||
onUpdate: async (value) => {
|
||||
const sampleRate =
|
||||
@ -94,10 +94,10 @@ export default {
|
||||
onEnabledChange: (enabled) => {
|
||||
if (enabled === true) {
|
||||
app.cores.settings.set("player.compressor", true)
|
||||
app.cores.player.compressor.attach()
|
||||
//app.cores.player.compressor.attach()
|
||||
} else {
|
||||
app.cores.settings.set("player.compressor", false)
|
||||
app.cores.player.compressor.detach()
|
||||
//app.cores.player.compressor.detach()
|
||||
}
|
||||
},
|
||||
extraActions: [
|
||||
@ -106,8 +106,9 @@ export default {
|
||||
title: "Default",
|
||||
icon: "MdRefresh",
|
||||
onClick: async (ctx) => {
|
||||
const values =
|
||||
await app.cores.player.compressor.presets.setCurrentPresetToDefault()
|
||||
const values = await app.cores.player
|
||||
.base()
|
||||
.processors.compressor.presets.setCurrentPresetToDefault()
|
||||
|
||||
ctx.updateCurrentValue(values)
|
||||
},
|
||||
@ -152,13 +153,14 @@ export default {
|
||||
],
|
||||
},
|
||||
onUpdate: (value) => {
|
||||
app.cores.player.compressor.presets.setToCurrent(value)
|
||||
app.cores.player
|
||||
.base()
|
||||
.processors.compressor.presets.setToCurrent(value)
|
||||
|
||||
return value
|
||||
},
|
||||
storaged: false,
|
||||
},
|
||||
|
||||
{
|
||||
id: "player.eq",
|
||||
title: "Equalizer",
|
||||
@ -172,8 +174,9 @@ export default {
|
||||
title: "Reset",
|
||||
icon: "MdRefresh",
|
||||
onClick: (ctx) => {
|
||||
const values =
|
||||
app.cores.player.eq.presets.setCurrentPresetToDefault()
|
||||
const values = app.cores.player
|
||||
.base()
|
||||
.processors.eq.presets.setCurrentPresetToDefault()
|
||||
|
||||
ctx.updateCurrentValue(values)
|
||||
},
|
||||
@ -260,7 +263,9 @@ export default {
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
app.cores.player.eq.presets.setToCurrent(values)
|
||||
app.cores.player
|
||||
.base()
|
||||
.processors.eq.presets.setToCurrent(values)
|
||||
|
||||
return value
|
||||
},
|
||||
|
@ -2,13 +2,11 @@ import { Switch } from "antd"
|
||||
import SlidersWithPresets from "../../../components/slidersWithPresets"
|
||||
|
||||
export default (props) => {
|
||||
return <SlidersWithPresets
|
||||
{...props}
|
||||
controller={app.cores.player.compressor.presets}
|
||||
extraHeaderItems={[
|
||||
<Switch
|
||||
onChange={props.onEnabledChange}
|
||||
/>
|
||||
]}
|
||||
/>
|
||||
}
|
||||
return (
|
||||
<SlidersWithPresets
|
||||
{...props}
|
||||
controller={app.cores.player.base().processors.compressor.presets}
|
||||
extraHeaderItems={[<Switch onChange={props.onEnabledChange} />]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
@ -1,8 +1,10 @@
|
||||
import SlidersWithPresets from "../../../components/slidersWithPresets"
|
||||
|
||||
export default (props) => {
|
||||
return <SlidersWithPresets
|
||||
{...props}
|
||||
controller={app.cores.player.eq.presets}
|
||||
/>
|
||||
}
|
||||
return (
|
||||
<SlidersWithPresets
|
||||
{...props}
|
||||
controller={app.cores.player.base().processors.eq.presets}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
99
packages/app/src/utils/compareObjectsByProperties/index.js
Normal file
99
packages/app/src/utils/compareObjectsByProperties/index.js
Normal 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
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -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 ssh
|
||||
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 ffmpeg
|
||||
|
||||
|
@ -8,17 +8,6 @@ const { Buffer } = require("node:buffer")
|
||||
const { webcrypto: crypto } = require("node:crypto")
|
||||
const { InfisicalClient } = require("@infisical/sdk")
|
||||
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
|
||||
process.argv.splice(1, 1)
|
||||
@ -164,34 +153,12 @@ async function Boot(main) {
|
||||
throw new Error("main class is not defined")
|
||||
}
|
||||
|
||||
const service_id = process.env.lb_service.id
|
||||
const { lb_service_id } = process.env
|
||||
|
||||
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 (
|
||||
process.env.INFISICAL_CLIENT_ID &&
|
||||
process.env.INFISICAL_CLIENT_SECRET
|
||||
@ -204,30 +171,35 @@ async function Boot(main) {
|
||||
|
||||
const instance = new main()
|
||||
|
||||
onExit(
|
||||
(code, signal) => {
|
||||
console.log(`[BOOT] Cleaning up...`)
|
||||
|
||||
sdk.shutdown()
|
||||
.then(() => console.log("Tracing terminated"))
|
||||
.catch((error) =>
|
||||
console.log("Error terminating tracing", error),
|
||||
)
|
||||
process.on("exit", (code) => {
|
||||
console.log(`[BOOT] Closing...`)
|
||||
|
||||
if (instance._fireClose) {
|
||||
instance._fireClose()
|
||||
} else {
|
||||
if (typeof instance.onClose === "function") {
|
||||
instance.onClose()
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
if (process.env.lb_service && process.send) {
|
||||
if (lb_service_id && process.send) {
|
||||
process.send({
|
||||
status: "ready",
|
||||
})
|
||||
|
@ -107,8 +107,7 @@ export async function handleChunkFile(
|
||||
{ tmpDir, headers, maxFileSize, maxChunkSize },
|
||||
) {
|
||||
return await new Promise(async (resolve, reject) => {
|
||||
const workPath = path.join(tmpDir, headers["uploader-file-id"])
|
||||
const chunksPath = path.join(workPath, "chunks")
|
||||
const chunksPath = path.join(tmpDir, "chunks")
|
||||
const chunkPath = path.join(
|
||||
chunksPath,
|
||||
headers["uploader-chunk-number"],
|
||||
@ -188,7 +187,7 @@ export async function handleChunkFile(
|
||||
// build data
|
||||
chunksPath: chunksPath,
|
||||
filePath: path.resolve(
|
||||
workPath,
|
||||
tmpDir,
|
||||
`${filename}.${extension}`,
|
||||
),
|
||||
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
|
||||
|
110
packages/server/classes/FFMPEGLib/index.js
Normal file
110
packages/server/classes/FFMPEGLib/index.js
Normal 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
|
@ -1,147 +1,138 @@
|
||||
import fs from "node:fs"
|
||||
import path from "node:path"
|
||||
import { exec } from "node:child_process"
|
||||
import { EventEmitter } from "node:events"
|
||||
|
||||
export default class MultiqualityHLSJob {
|
||||
constructor({
|
||||
input,
|
||||
outputDir,
|
||||
outputMasterName = "master.m3u8",
|
||||
levels,
|
||||
}) {
|
||||
this.input = input
|
||||
this.outputDir = outputDir
|
||||
this.levels = levels
|
||||
this.outputMasterName = outputMasterName
|
||||
import { FFMPEGLib, Utils } from "../FFMPEGLib"
|
||||
|
||||
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 = () => {
|
||||
const cmdStr = [
|
||||
this.bin,
|
||||
`-v quiet -stats`,
|
||||
`-i ${this.input}`,
|
||||
`-filter_complex`,
|
||||
]
|
||||
// set split args
|
||||
let splitLevels = [`[0:v]split=${this.params.levels.length}`]
|
||||
|
||||
// set split args
|
||||
let splitLevels = [
|
||||
`[0:v]split=${this.levels.length}`
|
||||
]
|
||||
this.params.levels.forEach((level, i) => {
|
||||
splitLevels[0] += `[v${i + 1}]`
|
||||
})
|
||||
|
||||
this.levels.forEach((level, i) => {
|
||||
splitLevels[0] += (`[v${i + 1}]`)
|
||||
})
|
||||
for (const [index, level] of this.params.levels.entries()) {
|
||||
if (level.original) {
|
||||
splitLevels.push(`[v1]copy[v1out]`)
|
||||
continue
|
||||
}
|
||||
|
||||
for (const [index, level] of this.levels.entries()) {
|
||||
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]`
|
||||
|
||||
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
|
||||
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)
|
||||
}
|
||||
|
||||
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(`-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`)
|
||||
|
||||
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()) {
|
||||
streamMapVar.push(`v:${index}`)
|
||||
}
|
||||
cmdStr.push(`"${streamMapVar.join(" ")}"`)
|
||||
cmdStr.push(`"stream_%v/stream.m3u8"`)
|
||||
|
||||
cmdStr.push(`"${streamMapVar.join(" ")}"`)
|
||||
cmdStr.push(`"stream_%v/stream.m3u8"`)
|
||||
return cmdStr.join(" ")
|
||||
}
|
||||
|
||||
return cmdStr.join(" ")
|
||||
}
|
||||
run = async () => {
|
||||
const cmdStr = this.buildArgs()
|
||||
|
||||
run = () => {
|
||||
const cmdStr = this.buildCommand()
|
||||
const outputPath =
|
||||
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)) {
|
||||
fs.mkdirSync(cwd, { recursive: true })
|
||||
}
|
||||
const inputProbe = await Utils.probe(this.params.input)
|
||||
|
||||
console.log(`[HLS] Started multiquality transcode`, {
|
||||
input: this.input,
|
||||
cwd: cwd,
|
||||
})
|
||||
try {
|
||||
const result = await this.ffmpeg({
|
||||
args: cmdStr,
|
||||
cwd: outputPath,
|
||||
onProcess: (process) => {
|
||||
this.handleProgress(
|
||||
process.stdout,
|
||||
parseFloat(inputProbe.format.duration),
|
||||
(progress) => {
|
||||
this.emit("progress", progress)
|
||||
},
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const process = exec(
|
||||
cmdStr,
|
||||
{
|
||||
cwd: cwd,
|
||||
},
|
||||
(error, stdout, stderr) => {
|
||||
if (error) {
|
||||
console.log(`[HLS] Failed to transcode >`, error)
|
||||
this.emit("end", {
|
||||
outputPath: outputPath,
|
||||
outputFile: outputFile,
|
||||
})
|
||||
|
||||
return this.events.emit("error", error)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
return result
|
||||
} catch (err) {
|
||||
return this.emit("error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,79 +1,72 @@
|
||||
import Redis from "ioredis"
|
||||
|
||||
export function composeURL({
|
||||
host,
|
||||
port,
|
||||
username,
|
||||
password,
|
||||
} = {}) {
|
||||
let url = "redis://"
|
||||
export function composeURL({ host, port, username, password } = {}) {
|
||||
let url = "redis://"
|
||||
|
||||
if (username && password) {
|
||||
url += username + ":" + password + "@"
|
||||
}
|
||||
if (username && password) {
|
||||
url += username + ":" + password + "@"
|
||||
}
|
||||
|
||||
url += host ?? "localhost"
|
||||
url += host ?? "localhost"
|
||||
|
||||
if (port) {
|
||||
url += ":" + port
|
||||
}
|
||||
if (port) {
|
||||
url += ":" + port
|
||||
}
|
||||
|
||||
return url
|
||||
return url
|
||||
}
|
||||
|
||||
export default () => {
|
||||
let { REDIS_HOST, REDIS_PORT, REDIS_NO_AUTH, REDIS_AUTH, REDIS_DB } = process.env
|
||||
export default (params = {}) => {
|
||||
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 = {
|
||||
host: REDIS_HOST,
|
||||
port: REDIS_PORT,
|
||||
lazyConnect: true,
|
||||
autoConnect: false
|
||||
}
|
||||
// if redis auth is provided, set username and password
|
||||
if (!ToBoolean(REDIS_NO_AUTH) && REDIS_AUTH) {
|
||||
const [user, password] = REDIS_AUTH.split(":")
|
||||
|
||||
if (!REDIS_NO_AUTH) {
|
||||
if (REDIS_AUTH) {
|
||||
const [user, password] = REDIS_AUTH.split(":")
|
||||
clientOptions.username = user
|
||||
clientOptions.password = password
|
||||
} else {
|
||||
console.log("⚠️ Redis auth is disabled")
|
||||
}
|
||||
|
||||
clientOptions.username = user
|
||||
clientOptions.password = password
|
||||
}
|
||||
} else {
|
||||
console.log("⚠️ Redis auth is disabled")
|
||||
}
|
||||
// if redis db is provided, set db
|
||||
if (REDIS_DB) {
|
||||
clientOptions.db = REDIS_DB
|
||||
}
|
||||
|
||||
if (REDIS_DB) {
|
||||
clientOptions.db = REDIS_DB
|
||||
}
|
||||
let client = new Redis(clientOptions)
|
||||
|
||||
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) => {
|
||||
console.error("❌ Redis client error:", error)
|
||||
})
|
||||
client.on("reconnecting", () => {
|
||||
console.log("🔄 Redis client reconnecting...")
|
||||
})
|
||||
|
||||
client.on("connect", () => {
|
||||
console.log(`✅ Redis client connected [${process.env.REDIS_HOST}]`)
|
||||
})
|
||||
const initialize = async () => {
|
||||
return await new Promise((resolve, reject) => {
|
||||
console.log(`🔌 Connecting to Redis client [${REDIS_HOST}]`)
|
||||
|
||||
client.on("reconnecting", () => {
|
||||
console.log("🔄 Redis client reconnecting...")
|
||||
})
|
||||
client.connect(resolve)
|
||||
})
|
||||
}
|
||||
|
||||
const initialize = async () => {
|
||||
return await new Promise((resolve, reject) => {
|
||||
console.log(`🔌 Connecting to Redis client [${REDIS_HOST}]`)
|
||||
|
||||
client.connect(resolve)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
client,
|
||||
initialize
|
||||
}
|
||||
}
|
||||
return {
|
||||
client,
|
||||
initialize,
|
||||
}
|
||||
}
|
||||
|
@ -1,112 +1,108 @@
|
||||
import fs from "node:fs"
|
||||
import path from "node:path"
|
||||
import { exec } from "node:child_process"
|
||||
import { EventEmitter } from "node:events"
|
||||
|
||||
export default class SegmentedAudioMPDJob {
|
||||
constructor({
|
||||
input,
|
||||
outputDir,
|
||||
outputMasterName = "master.mpd",
|
||||
import { FFMPEGLib, Utils } from "../FFMPEGLib"
|
||||
|
||||
audioCodec = "aac",
|
||||
audioBitrate = undefined,
|
||||
audioSampleRate = undefined,
|
||||
segmentTime = 10,
|
||||
}) {
|
||||
this.input = input
|
||||
this.outputDir = outputDir
|
||||
this.outputMasterName = outputMasterName
|
||||
export default class SegmentedAudioMPDJob extends FFMPEGLib {
|
||||
constructor(params = {}) {
|
||||
super()
|
||||
|
||||
this.audioCodec = audioCodec
|
||||
this.audioBitrate = audioBitrate
|
||||
this.segmentTime = segmentTime
|
||||
this.audioSampleRate = audioSampleRate
|
||||
this.params = {
|
||||
outputMasterName: "master.mpd",
|
||||
audioCodec: "libopus",
|
||||
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 = () => {
|
||||
const cmdStr = [
|
||||
this.bin,
|
||||
`-v quiet -stats`,
|
||||
`-i ${this.input}`,
|
||||
`-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.params.audioSampleRate !== "undefined" &&
|
||||
this.params.audioSampleRate !== "default"
|
||||
) {
|
||||
args.push(`-ar ${this.params.audioSampleRate}`)
|
||||
}
|
||||
|
||||
if (typeof this.audioBitrate !== "undefined") {
|
||||
cmdStr.push(`-b:a ${this.audioBitrate}`)
|
||||
}
|
||||
args.push(this.params.outputMasterName)
|
||||
|
||||
if (typeof this.audioSampleRate !== "undefined") {
|
||||
cmdStr.push(`-ar ${this.audioSampleRate}`)
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
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 = () => {
|
||||
const cmdStr = this.buildCommand()
|
||||
if (!fs.existsSync(outputPath)) {
|
||||
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)) {
|
||||
fs.mkdirSync(cwd, { recursive: true })
|
||||
}
|
||||
let outputProbe = await Utils.probe(outputFile)
|
||||
|
||||
console.log(`[DASH] Started audio segmentation`, {
|
||||
input: this.input,
|
||||
cwd: cwd,
|
||||
})
|
||||
this.emit("end", {
|
||||
probe: {
|
||||
input: inputProbe,
|
||||
output: outputProbe,
|
||||
},
|
||||
outputPath: outputPath,
|
||||
outputFile: outputFile,
|
||||
})
|
||||
|
||||
const process = exec(
|
||||
cmdStr,
|
||||
{
|
||||
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
|
||||
}
|
||||
}
|
||||
return result
|
||||
} catch (err) {
|
||||
return this.emit("error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,106 +1,118 @@
|
||||
const Minio = require("minio")
|
||||
import path from "path"
|
||||
import path from "node:path"
|
||||
import { Client } from "minio"
|
||||
|
||||
export const generateDefaultBucketPolicy = (payload) => {
|
||||
const { bucketName } = payload
|
||||
const { bucketName } = payload
|
||||
|
||||
if (!bucketName) {
|
||||
throw new Error("bucketName is required")
|
||||
}
|
||||
if (!bucketName) {
|
||||
throw new Error("bucketName is required")
|
||||
}
|
||||
|
||||
return {
|
||||
Version: "2012-10-17",
|
||||
Statement: [
|
||||
{
|
||||
Action: [
|
||||
"s3:GetObject"
|
||||
],
|
||||
Effect: "Allow",
|
||||
Principal: {
|
||||
AWS: [
|
||||
"*"
|
||||
]
|
||||
},
|
||||
Resource: [
|
||||
`arn:aws:s3:::${bucketName}/*`
|
||||
],
|
||||
Sid: ""
|
||||
}
|
||||
]
|
||||
}
|
||||
return {
|
||||
Version: "2012-10-17",
|
||||
Statement: [
|
||||
{
|
||||
Action: ["s3:GetObject"],
|
||||
Effect: "Allow",
|
||||
Principal: {
|
||||
AWS: ["*"],
|
||||
},
|
||||
Resource: [`arn:aws:s3:::${bucketName}/*`],
|
||||
Sid: "",
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
export class StorageClient extends Minio.Client {
|
||||
constructor(options) {
|
||||
super(options)
|
||||
export class StorageClient extends Client {
|
||||
constructor(options) {
|
||||
super(options)
|
||||
|
||||
this.defaultBucket = String(options.defaultBucket)
|
||||
this.defaultRegion = String(options.defaultRegion)
|
||||
}
|
||||
this.defaultBucket = String(options.defaultBucket)
|
||||
this.defaultRegion = String(options.defaultRegion)
|
||||
this.setupBucket = Boolean(options.setupBucket)
|
||||
this.cdnUrl = options.cdnUrl
|
||||
}
|
||||
|
||||
composeRemoteURL = (key, extraKey) => {
|
||||
let _path = path.join(this.defaultBucket, key)
|
||||
composeRemoteURL = (key, extraKey) => {
|
||||
let _path = path.join(this.defaultBucket, key)
|
||||
|
||||
if (typeof extraKey === "string") {
|
||||
_path = path.join(_path, extraKey)
|
||||
}
|
||||
if (typeof extraKey === "string") {
|
||||
_path = path.join(_path, extraKey)
|
||||
}
|
||||
|
||||
return `${this.protocol}//${this.host}:${this.port}/${_path}`
|
||||
}
|
||||
if (this.cdnUrl) {
|
||||
return `${this.cdnUrl}/${_path}`
|
||||
}
|
||||
|
||||
setDefaultBucketPolicy = async (bucketName) => {
|
||||
const policy = generateDefaultBucketPolicy({ bucketName })
|
||||
return `${this.protocol}//${this.host}:${this.port}/${_path}`
|
||||
}
|
||||
|
||||
return this.setBucketPolicy(bucketName, JSON.stringify(policy))
|
||||
}
|
||||
setDefaultBucketPolicy = async (bucketName) => {
|
||||
const policy = generateDefaultBucketPolicy({ bucketName })
|
||||
|
||||
initialize = async () => {
|
||||
console.log("🔌 Checking if storage client have default bucket...")
|
||||
return this.setBucketPolicy(bucketName, JSON.stringify(policy))
|
||||
}
|
||||
|
||||
try {
|
||||
const bucketExists = await this.bucketExists(this.defaultBucket)
|
||||
initialize = async () => {
|
||||
console.log("🔌 Checking if storage client have default bucket...")
|
||||
|
||||
if (!bucketExists) {
|
||||
console.warn("🪣 Default bucket not exists! Creating new bucket...")
|
||||
if (this.setupBucket !== false) {
|
||||
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.setDefaultBucketPolicy(this.defaultBucket)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to check if default bucket exists or create default bucket >`, error)
|
||||
}
|
||||
await this.makeBucket(this.defaultBucket, "s3")
|
||||
|
||||
try {
|
||||
// check if default bucket policy exists
|
||||
const bucketPolicy = await this.getBucketPolicy(this.defaultBucket).catch(() => {
|
||||
return null
|
||||
})
|
||||
// set default bucket policy
|
||||
await this.setDefaultBucketPolicy(this.defaultBucket)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to check if default bucket exists or create default bucket >`,
|
||||
error,
|
||||
)
|
||||
}
|
||||
|
||||
if (!bucketPolicy) {
|
||||
// set default bucket policy
|
||||
await this.setDefaultBucketPolicy(this.defaultBucket)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to get or set default bucket policy >`, error)
|
||||
}
|
||||
try {
|
||||
// check if default bucket policy exists
|
||||
const bucketPolicy = await this.getBucketPolicy(
|
||||
this.defaultBucket,
|
||||
).catch(() => {
|
||||
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) => {
|
||||
return new StorageClient({
|
||||
endPoint: process.env.S3_ENDPOINT,
|
||||
port: Number(process.env.S3_PORT),
|
||||
useSSL: ToBoolean(process.env.S3_USE_SSL),
|
||||
accessKey: process.env.S3_ACCESS_KEY,
|
||||
secretKey: process.env.S3_SECRET_KEY,
|
||||
defaultBucket: process.env.S3_BUCKET,
|
||||
defaultRegion: process.env.S3_REGION,
|
||||
...options,
|
||||
})
|
||||
return new StorageClient({
|
||||
endPoint: process.env.S3_ENDPOINT,
|
||||
port: Number(process.env.S3_PORT),
|
||||
useSSL: ToBoolean(process.env.S3_USE_SSL),
|
||||
accessKey: process.env.S3_ACCESS_KEY,
|
||||
secretKey: process.env.S3_SECRET_KEY,
|
||||
defaultBucket: process.env.S3_BUCKET,
|
||||
defaultRegion: process.env.S3_REGION,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
export default createStorageClientInstance
|
||||
export default createStorageClientInstance
|
||||
|
@ -56,14 +56,8 @@ export default class TaskQueueManager {
|
||||
registerQueueEvents = (worker) => {
|
||||
worker.on("progress", (job, progress) => {
|
||||
try {
|
||||
console.log(`Job ${job.id} reported progress: ${progress}%`)
|
||||
|
||||
if (job.data.sseChannelId) {
|
||||
global.sse.sendToChannel(job.data.sseChannelId, {
|
||||
status: "progress",
|
||||
events: "job_progress",
|
||||
progress,
|
||||
})
|
||||
global.sse.sendToChannel(job.data.sseChannelId, progress)
|
||||
}
|
||||
} catch (error) {
|
||||
// manejar error
|
||||
@ -76,8 +70,9 @@ export default class TaskQueueManager {
|
||||
|
||||
if (job.data.sseChannelId) {
|
||||
global.sse.sendToChannel(job.data.sseChannelId, {
|
||||
status: "done",
|
||||
result,
|
||||
event: "done",
|
||||
state: "done",
|
||||
result: result,
|
||||
})
|
||||
}
|
||||
} catch (error) {}
|
||||
@ -89,7 +84,8 @@ export default class TaskQueueManager {
|
||||
|
||||
if (job.data.sseChannelId) {
|
||||
global.sse.sendToChannel(job.data.sseChannelId, {
|
||||
status: "error",
|
||||
event: "error",
|
||||
state: "error",
|
||||
result: error.message,
|
||||
})
|
||||
}
|
||||
@ -122,9 +118,9 @@ export default class TaskQueueManager {
|
||||
)
|
||||
|
||||
await global.sse.sendToChannel(sseChannelId, {
|
||||
status: "progress",
|
||||
events: "job_queued",
|
||||
progress: 5,
|
||||
event: "job_queued",
|
||||
state: "progress",
|
||||
percent: 5,
|
||||
})
|
||||
}
|
||||
|
||||
|
33
packages/server/classes/Transformation/handlers/a-dash.js
Normal file
33
packages/server/classes/Transformation/handlers/a-dash.js
Normal 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()
|
||||
})
|
||||
}
|
45
packages/server/classes/Transformation/handlers/mq-hls.js
Normal file
45
packages/server/classes/Transformation/handlers/mq-hls.js
Normal 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()
|
||||
})
|
||||
}
|
63
packages/server/classes/Transformation/handlers/optimize.js
Normal file
63
packages/server/classes/Transformation/handlers/optimize.js
Normal 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,
|
||||
}
|
||||
}
|
26
packages/server/classes/Transformation/index.ts
Normal file
26
packages/server/classes/Transformation/index.ts
Normal 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
|
154
packages/server/classes/Upload/index.ts
Normal file
154
packages/server/classes/Upload/index.ts
Normal 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
|
||||
}
|
||||
}
|
65
packages/server/classes/Upload/putObject.js
Normal file
65
packages/server/classes/Upload/putObject.js
Normal 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
|
||||
}
|
@ -1,35 +1,36 @@
|
||||
export default {
|
||||
name: "MusicRelease",
|
||||
collection: "music_releases",
|
||||
schema: {
|
||||
user_id: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
list: {
|
||||
type: Object,
|
||||
default: [],
|
||||
required: true
|
||||
},
|
||||
cover: {
|
||||
type: String,
|
||||
default: "https://storage.ragestudio.net/comty-static-assets/default_song.png"
|
||||
},
|
||||
created_at: {
|
||||
type: Date,
|
||||
required: true
|
||||
},
|
||||
public: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
name: "MusicRelease",
|
||||
collection: "music_releases",
|
||||
schema: {
|
||||
user_id: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
items: {
|
||||
type: Array,
|
||||
default: [],
|
||||
required: true,
|
||||
},
|
||||
cover: {
|
||||
type: String,
|
||||
default:
|
||||
"https://storage.ragestudio.net/comty-static-assets/default_song.png",
|
||||
},
|
||||
created_at: {
|
||||
type: Date,
|
||||
required: true,
|
||||
},
|
||||
public: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
@ -1,46 +1,43 @@
|
||||
export default {
|
||||
name: "Track",
|
||||
collection: "tracks",
|
||||
schema: {
|
||||
source: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
album: {
|
||||
type: String,
|
||||
},
|
||||
artists: {
|
||||
type: Array,
|
||||
},
|
||||
metadata: {
|
||||
type: Object,
|
||||
},
|
||||
explicit: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
public: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
publish_date: {
|
||||
type: Date,
|
||||
},
|
||||
cover: {
|
||||
type: String,
|
||||
default: "https://storage.ragestudio.net/comty-static-assets/default_song.png"
|
||||
},
|
||||
publisher: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
lyrics_enabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
}
|
||||
}
|
||||
name: "Track",
|
||||
collection: "tracks",
|
||||
schema: {
|
||||
source: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
album: {
|
||||
type: String,
|
||||
},
|
||||
artist: {
|
||||
type: String,
|
||||
},
|
||||
metadata: {
|
||||
type: Object,
|
||||
},
|
||||
explicit: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
public: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
publish_date: {
|
||||
type: Date,
|
||||
},
|
||||
cover: {
|
||||
type: String,
|
||||
default:
|
||||
"https://storage.ragestudio.net/comty-static-assets/default_song.png",
|
||||
},
|
||||
publisher: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
@ -1,77 +1,76 @@
|
||||
export default {
|
||||
name: "User",
|
||||
collection: "accounts",
|
||||
schema: {
|
||||
username: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
password: {
|
||||
type: String,
|
||||
required: true,
|
||||
select: false
|
||||
},
|
||||
email: {
|
||||
type: String,
|
||||
required: true,
|
||||
select: false
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
created_at: {
|
||||
type: String
|
||||
},
|
||||
public_name: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
cover: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
avatar: {
|
||||
type:
|
||||
String,
|
||||
default: null
|
||||
},
|
||||
roles: {
|
||||
type: Array,
|
||||
default: []
|
||||
},
|
||||
verified: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
badges: {
|
||||
type: Array,
|
||||
default: []
|
||||
},
|
||||
links: {
|
||||
type: Array,
|
||||
default: []
|
||||
},
|
||||
location: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
birthday: {
|
||||
type: Date,
|
||||
default: null,
|
||||
select: false
|
||||
},
|
||||
accept_tos: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
activated: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
}
|
||||
}
|
||||
name: "User",
|
||||
collection: "accounts",
|
||||
schema: {
|
||||
username: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
password: {
|
||||
type: String,
|
||||
required: true,
|
||||
select: false,
|
||||
},
|
||||
email: {
|
||||
type: String,
|
||||
required: true,
|
||||
select: false,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
created_at: {
|
||||
type: String,
|
||||
},
|
||||
public_name: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
cover: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
avatar: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
roles: {
|
||||
type: Array,
|
||||
default: [],
|
||||
},
|
||||
verified: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
badges: {
|
||||
type: Array,
|
||||
default: [],
|
||||
},
|
||||
links: {
|
||||
type: Array,
|
||||
default: [],
|
||||
},
|
||||
location: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
birthday: {
|
||||
type: Date,
|
||||
default: null,
|
||||
select: false,
|
||||
},
|
||||
accept_tos: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
activated: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
2
packages/server/dev.sh
Executable file
2
packages/server/dev.sh
Executable file
@ -0,0 +1,2 @@
|
||||
#!/bin/bash
|
||||
node --run dev
|
@ -181,19 +181,11 @@ export default class Gateway {
|
||||
serviceId: id,
|
||||
path: 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 (typeof this.gateway.applyConfiguration === "function") {
|
||||
await this.gateway.applyConfiguration()
|
||||
|
@ -6,6 +6,7 @@ import defaults from "linebridge/dist/defaults"
|
||||
|
||||
const localNginxBinary = path.resolve(process.cwd(), "nginx-bin")
|
||||
const serverPkg = require("../../../package.json")
|
||||
|
||||
/**
|
||||
* NginxManager - Optimized version that batches configurations
|
||||
* Waits for all services to register before applying configuration
|
||||
@ -253,7 +254,7 @@ http {
|
||||
|
||||
if (debugFlag) {
|
||||
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 || {}
|
||||
|
||||
this.routes.set(normalizedPath, {
|
||||
serviceId,
|
||||
target,
|
||||
serviceId: serviceId,
|
||||
target: target,
|
||||
pathRewrite: effectivePathRewrite,
|
||||
websocket: !!websocket,
|
||||
})
|
||||
|
@ -55,7 +55,8 @@ export default class Service {
|
||||
|
||||
this.instance = await spawnService({
|
||||
id: this.id,
|
||||
service: this.path,
|
||||
service: this,
|
||||
path: this.path,
|
||||
cwd: this.cwd,
|
||||
onClose: this.handleClose.bind(this),
|
||||
onError: this.handleError.bind(this),
|
||||
@ -140,8 +141,7 @@ export default class Service {
|
||||
|
||||
// Kill the current process if is running
|
||||
if (this.instance.exitCode === null) {
|
||||
console.log(`[${this.id}] Killing current process...`)
|
||||
await this.instance.kill("SIGKILL")
|
||||
await this.instance.kill()
|
||||
}
|
||||
|
||||
// Start a new process
|
||||
@ -153,17 +153,13 @@ export default class Service {
|
||||
/**
|
||||
* Stop the service
|
||||
*/
|
||||
async stop() {
|
||||
stop() {
|
||||
console.log(`[${this.id}] Stopping service...`)
|
||||
|
||||
if (this.fileWatcher) {
|
||||
await this.fileWatcher.close()
|
||||
this.fileWatcher = null
|
||||
}
|
||||
this.instance.kill()
|
||||
|
||||
if (this.instance) {
|
||||
await this.instance.kill("SIGKILL")
|
||||
this.instance = null
|
||||
if (this.fileWatcher) {
|
||||
this.fileWatcher.close()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -3,21 +3,29 @@ import createServiceLogTransformer from "./createServiceLogTransformer"
|
||||
|
||||
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 = {
|
||||
...process.env,
|
||||
lb_service: {
|
||||
id: service.id,
|
||||
index: service.index,
|
||||
},
|
||||
lb_service_id: service.id,
|
||||
lb_service_path: service.path,
|
||||
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,
|
||||
silent: true,
|
||||
cwd: cwd,
|
||||
env: instanceEnv,
|
||||
killSignal: "SIGTERM",
|
||||
})
|
||||
|
||||
instance.logs = {
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comty/server",
|
||||
"version": "1.31.0@alpha",
|
||||
"version": "1.38.0@alpha",
|
||||
"license": "ComtyLicense",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
|
165
packages/server/scripts/installLatestFfmpeg.sh
Executable file
165
packages/server/scripts/installLatestFfmpeg.sh
Executable 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
|
@ -2,31 +2,25 @@ import bcrypt from "bcrypt"
|
||||
import { User } from "@db_models"
|
||||
|
||||
export default async ({ username, password, hash }, user) => {
|
||||
if (typeof user === "undefined") {
|
||||
let isEmail = username.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)
|
||||
if (typeof user === "undefined") {
|
||||
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) {
|
||||
throw new OperationError(401, "User not found")
|
||||
}
|
||||
if (!user) {
|
||||
throw new OperationError(401, "User not found")
|
||||
}
|
||||
|
||||
if (user.disabled == true) {
|
||||
throw new OperationError(401, "User is disabled")
|
||||
}
|
||||
if (user.disabled == true) {
|
||||
throw new OperationError(401, "User is disabled")
|
||||
}
|
||||
|
||||
if (typeof hash !== "undefined") {
|
||||
if (user.password !== hash) {
|
||||
throw new OperationError(401, "Invalid credentials")
|
||||
}
|
||||
} else {
|
||||
if (!bcrypt.compareSync(password, user.password)) {
|
||||
throw new OperationError(401, "Invalid credentials")
|
||||
}
|
||||
}
|
||||
if (!bcrypt.compareSync(password, user.password)) {
|
||||
throw new OperationError(401, "Invalid credentials")
|
||||
}
|
||||
|
||||
return user
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
@ -1,4 +1,3 @@
|
||||
{
|
||||
"name": "auth",
|
||||
"version": "1.0.0"
|
||||
"name": "auth"
|
||||
}
|
||||
|
@ -1,4 +1,3 @@
|
||||
{
|
||||
"name": "chats",
|
||||
"version": "0.60.2"
|
||||
}
|
||||
"name": "chats"
|
||||
}
|
||||
|
@ -3,40 +3,42 @@ import nodemailer from "nodemailer"
|
||||
import DbManager from "@shared-classes/DbManager"
|
||||
|
||||
import SharedMiddlewares from "@shared-middlewares"
|
||||
|
||||
export default class API extends Server {
|
||||
static refName = "ems"
|
||||
static useEngine = "hyper-express"
|
||||
static routesPath = `${__dirname}/routes`
|
||||
static listen_port = process.env.HTTP_LISTEN_PORT ?? 3007
|
||||
static refName = "ems"
|
||||
static useEngine = "hyper-express"
|
||||
static routesPath = `${__dirname}/routes`
|
||||
static listen_port = process.env.HTTP_LISTEN_PORT ?? 3007
|
||||
|
||||
middlewares = {
|
||||
...SharedMiddlewares
|
||||
}
|
||||
middlewares = {
|
||||
...SharedMiddlewares,
|
||||
}
|
||||
|
||||
contexts = {
|
||||
db: new DbManager(),
|
||||
mailTransporter: nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOSTNAME,
|
||||
port: process.env.SMTP_PORT ?? 587,
|
||||
secure: ToBoolean(process.env.SMTP_SECURE) ?? false,
|
||||
auth: {
|
||||
user: process.env.SMTP_USERNAME,
|
||||
pass: process.env.SMTP_PASSWORD,
|
||||
},
|
||||
}),
|
||||
}
|
||||
contexts = {
|
||||
db: new DbManager(),
|
||||
mailTransporter: nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOSTNAME,
|
||||
port: process.env.SMTP_PORT ?? 587,
|
||||
secure: ToBoolean(process.env.SMTP_SECURE) ?? false,
|
||||
auth: {
|
||||
user: process.env.SMTP_USERNAME,
|
||||
pass: process.env.SMTP_PASSWORD,
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
ipcEvents = {
|
||||
"account:activation:send": require("./ipcEvents/accountActivation").default,
|
||||
"new:login": require("./ipcEvents/newLogin").default,
|
||||
"mfa:send": require("./ipcEvents/mfaSend").default,
|
||||
"apr:send": require("./ipcEvents/aprSend").default,
|
||||
"password:changed": require("./ipcEvents/passwordChanged").default,
|
||||
}
|
||||
ipcEvents = {
|
||||
"account:activation:send": require("./ipcEvents/accountActivation")
|
||||
.default,
|
||||
"new:login": require("./ipcEvents/newLogin").default,
|
||||
"mfa:send": require("./ipcEvents/mfaSend").default,
|
||||
"apr:send": require("./ipcEvents/aprSend").default,
|
||||
"password:changed": require("./ipcEvents/passwordChanged").default,
|
||||
}
|
||||
|
||||
async onInitialize() {
|
||||
await this.contexts.db.initialize()
|
||||
}
|
||||
async onInitialize() {
|
||||
await this.contexts.db.initialize()
|
||||
}
|
||||
}
|
||||
|
||||
Boot(API)
|
||||
Boot(API)
|
||||
|
@ -1,10 +1,7 @@
|
||||
{
|
||||
"name": "ems",
|
||||
"description": "External Messaging Service (SMS, EMAIL, PUSH)",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"handlebars": "^4.7.8",
|
||||
"nodemailer": "^6.9.11",
|
||||
"web-push": "^3.6.7"
|
||||
}
|
||||
"name": "ems",
|
||||
"dependencies": {
|
||||
"handlebars": "^4.7.8",
|
||||
"nodemailer": "^6.9.11"
|
||||
}
|
||||
}
|
||||
|
@ -104,11 +104,9 @@ export function createAssembleChunksPromise({
|
||||
|
||||
export async function handleChunkFile(
|
||||
fileStream,
|
||||
{ tmpDir, headers, maxFileSize, maxChunkSize },
|
||||
{ chunksPath, outputDir, headers, maxFileSize, maxChunkSize },
|
||||
) {
|
||||
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(
|
||||
chunksPath,
|
||||
headers["uploader-chunk-number"],
|
||||
@ -125,17 +123,6 @@ export async function handleChunkFile(
|
||||
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 writeStream = fs.createWriteStream(chunkPath)
|
||||
@ -172,25 +159,18 @@ export async function handleChunkFile(
|
||||
}
|
||||
|
||||
if (isLast) {
|
||||
const mimetype = mimetypes.lookup(
|
||||
headers["uploader-original-name"],
|
||||
)
|
||||
const extension = mimetypes.extension(mimetype)
|
||||
// const mimetype = mimetypes.lookup(
|
||||
// headers["uploader-original-name"],
|
||||
// )
|
||||
// const extension = mimetypes.extension(mimetype)
|
||||
|
||||
let filename = headers["uploader-file-id"]
|
||||
|
||||
if (headers["uploader-use-date"] === "true") {
|
||||
filename = `${filename}_${Date.now()}`
|
||||
}
|
||||
let filename = nanoid()
|
||||
|
||||
return resolve(
|
||||
createAssembleChunksPromise({
|
||||
// build data
|
||||
chunksPath: chunksPath,
|
||||
filePath: path.resolve(
|
||||
workPath,
|
||||
`${filename}.${extension}`,
|
||||
),
|
||||
filePath: path.resolve(outputDir, filename),
|
||||
maxFileSize: maxFileSize,
|
||||
}),
|
||||
)
|
@ -1,21 +1,21 @@
|
||||
import { Server } from "linebridge"
|
||||
|
||||
import B2 from "backblaze-b2"
|
||||
|
||||
import DbManager from "@shared-classes/DbManager"
|
||||
import RedisClient from "@shared-classes/RedisClient"
|
||||
import StorageClient from "@shared-classes/StorageClient"
|
||||
import CacheService from "@shared-classes/CacheService"
|
||||
import SSEManager from "@shared-classes/SSEManager"
|
||||
import SharedMiddlewares from "@shared-middlewares"
|
||||
import LimitsClass from "@shared-classes/Limits"
|
||||
import TaskQueueManager from "@shared-classes/TaskQueueManager"
|
||||
|
||||
import SharedMiddlewares from "@shared-middlewares"
|
||||
|
||||
class API extends Server {
|
||||
static refName = "files"
|
||||
static useEngine = "hyper-express"
|
||||
static useEngine = "hyper-express-ng"
|
||||
static routesPath = `${__dirname}/routes`
|
||||
static listen_port = process.env.HTTP_LISTEN_PORT ?? 3002
|
||||
static enableWebsockets = true
|
||||
//static enableWebsockets = true
|
||||
|
||||
middlewares = {
|
||||
...SharedMiddlewares,
|
||||
@ -24,10 +24,13 @@ class API extends Server {
|
||||
contexts = {
|
||||
db: new DbManager(),
|
||||
cache: new CacheService(),
|
||||
SSEManager: new SSEManager(),
|
||||
redis: RedisClient({
|
||||
maxRetriesPerRequest: null,
|
||||
}),
|
||||
limits: {},
|
||||
storage: StorageClient(),
|
||||
b2Storage: null,
|
||||
SSEManager: new SSEManager(),
|
||||
limits: {},
|
||||
}
|
||||
|
||||
queuesManager = new TaskQueueManager(
|
||||
@ -41,27 +44,35 @@ class API extends Server {
|
||||
global.sse = this.contexts.SSEManager
|
||||
|
||||
if (process.env.B2_KEY_ID && process.env.B2_APP_KEY) {
|
||||
this.contexts.b2Storage = new B2({
|
||||
applicationKeyId: process.env.B2_KEY_ID,
|
||||
applicationKey: process.env.B2_APP_KEY,
|
||||
this.contexts.b2Storage = StorageClient({
|
||||
endPoint: process.env.B2_ENDPOINT,
|
||||
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.authorize()
|
||||
await this.contexts.b2Storage.initialize()
|
||||
} else {
|
||||
console.warn(
|
||||
"B2 storage not configured on environment, skipping...",
|
||||
)
|
||||
}
|
||||
|
||||
await this.contexts.redis.initialize()
|
||||
await this.queuesManager.initialize({
|
||||
redisOptions: this.engine.ws.redis.options,
|
||||
redisOptions: this.contexts.redis.client,
|
||||
})
|
||||
await this.contexts.db.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
|
||||
|
||||
this.contexts.limits = await LimitsClass.get()
|
||||
|
@ -1,20 +1,10 @@
|
||||
{
|
||||
"name": "files",
|
||||
"version": "0.60.2",
|
||||
"dependencies": {
|
||||
"backblaze-b2": "^1.7.0",
|
||||
"busboy": "^1.6.0",
|
||||
"content-range": "^2.0.2",
|
||||
"ffmpeg-static": "^5.2.0",
|
||||
"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"
|
||||
}
|
||||
"name": "files",
|
||||
"dependencies": {
|
||||
"file-type": "^20.4.1",
|
||||
"fluent-ffmpeg": "^2.1.2",
|
||||
"mime-types": "^2.1.35",
|
||||
"p-map": "4",
|
||||
"sharp": "0.32.6"
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
},
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
29
packages/server/services/files/queues/fileProcess/index.js
Normal file
29
packages/server/services/files/queues/fileProcess/index.js
Normal 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
|
||||
}
|
||||
},
|
||||
}
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
@ -1,21 +1,12 @@
|
||||
import { Duplex } from "node:stream"
|
||||
import path from "node:path"
|
||||
import fs from "node:fs"
|
||||
import RemoteUpload from "@services/remoteUpload"
|
||||
import {
|
||||
checkChunkUploadHeaders,
|
||||
handleChunkFile,
|
||||
} from "@classes/ChunkFileUpload"
|
||||
|
||||
import { checkChunkUploadHeaders, handleChunkFile } from "@classes/ChunkFile"
|
||||
import Upload from "@shared-classes/Upload"
|
||||
import bufferToStream from "@shared-utils/bufferToStream"
|
||||
|
||||
const availableProviders = ["b2", "standard"]
|
||||
|
||||
function bufferToStream(bf) {
|
||||
let tmp = new Duplex()
|
||||
tmp.push(bf)
|
||||
tmp.push(null)
|
||||
return tmp
|
||||
}
|
||||
|
||||
export default {
|
||||
useContext: ["cache", "limits"],
|
||||
middlewares: ["withAuthentication"],
|
||||
@ -25,14 +16,16 @@ export default {
|
||||
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,
|
||||
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:
|
||||
parseInt(this.default.contexts.limits.maxFileSizeInMB) *
|
||||
1024 *
|
||||
@ -42,93 +35,91 @@ export default {
|
||||
1024 *
|
||||
1024,
|
||||
useCompression: true,
|
||||
useProvider: "standard",
|
||||
useProvider: req.headers["use-provider"] ?? "standard",
|
||||
}
|
||||
|
||||
// const user = await req.auth.user()
|
||||
|
||||
// if (user.roles.includes("admin")) {
|
||||
// // maxFileSize for admins 100GB
|
||||
// limits.maxFileSize = 100 * 1024 * 1024 * 1024
|
||||
|
||||
// // optional compression for admins
|
||||
// limits.useCompression = req.headers["use-compression"] ?? false
|
||||
|
||||
// limits.useProvider = req.headers["provider-type"] ?? "b2"
|
||||
// }
|
||||
|
||||
// check if provider is valid
|
||||
if (!availableProviders.includes(limits.useProvider)) {
|
||||
if (!availableProviders.includes(config.useProvider)) {
|
||||
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())
|
||||
|
||||
let result = await handleChunkFile(dataStream, {
|
||||
tmpDir: tmpPath,
|
||||
let assemble = await handleChunkFile(dataStream, {
|
||||
chunksPath: chunksPath,
|
||||
outputDir: assembledPath,
|
||||
headers: req.headers,
|
||||
maxFileSize: limits.maxFileSize,
|
||||
maxChunkSize: limits.maxChunkSize,
|
||||
maxFileSize: config.maxFileSize,
|
||||
maxChunkSize: config.maxChunkSize,
|
||||
})
|
||||
|
||||
if (typeof result === "function") {
|
||||
if (typeof assemble === "function") {
|
||||
try {
|
||||
result = await result()
|
||||
assemble = await assemble()
|
||||
|
||||
if (req.headers["transmux"] || limits.useCompression === true) {
|
||||
// add a background task
|
||||
let transformations = req.headers["transformations"]
|
||||
|
||||
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(
|
||||
"remote_upload",
|
||||
{
|
||||
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,
|
||||
},
|
||||
"file-process",
|
||||
payload,
|
||||
{
|
||||
useSSE: true,
|
||||
},
|
||||
)
|
||||
|
||||
const sseChannelId = job.sseChannelId
|
||||
|
||||
return {
|
||||
uploadId: uploadId,
|
||||
sseChannelId: sseChannelId,
|
||||
eventChannelURL: `${req.headers["x-forwarded-proto"] || req.protocol}://${req.get("host")}/upload/sse_events/${sseChannelId}`,
|
||||
uploadId: payload.uploadId,
|
||||
sseChannelId: job.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(
|
||||
error.code ?? 500,
|
||||
error.message ?? "Failed to upload file",
|
||||
)
|
||||
return await Upload.fileHandle(payload)
|
||||
} catch (error) {
|
||||
await fs.promises.rm(workPath, { recursive: true })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: 1,
|
||||
next: true,
|
||||
chunkNumber: req.headers["uploader-chunk-number"],
|
||||
config: config,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
@ -1,48 +1,50 @@
|
||||
import path from "node:path"
|
||||
import fs from "node:fs"
|
||||
|
||||
import RemoteUpload from "@services/remoteUpload"
|
||||
import Upload from "@shared-classes/Upload"
|
||||
|
||||
export default {
|
||||
useContext: ["cache"],
|
||||
middlewares: [
|
||||
"withAuthentication",
|
||||
],
|
||||
fn: async (req, res) => {
|
||||
const { cache } = this.default.contexts
|
||||
useContext: ["cache"],
|
||||
middlewares: ["withAuthentication"],
|
||||
fn: async (req, res) => {
|
||||
const workPath = path.resolve(
|
||||
this.default.contexts.cache.constructor.cachePath,
|
||||
`${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
|
||||
let tmpPath = path.resolve(userPath, `${Date.now()}`)
|
||||
await req.multipart(async (field) => {
|
||||
if (!field.file) {
|
||||
throw new OperationError(400, "Missing file")
|
||||
}
|
||||
|
||||
await req.multipart(async (field) => {
|
||||
if (!field.file) {
|
||||
throw new OperationError(400, "Missing file")
|
||||
}
|
||||
localFilepath = path.join(workPath, "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) {
|
||||
await fs.promises.mkdir(tmpPath, { recursive: true })
|
||||
}
|
||||
if (transformations) {
|
||||
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({
|
||||
parentDir: req.auth.session.user_id,
|
||||
source: localFilepath,
|
||||
service: providerType,
|
||||
useCompression: ToBoolean(req.headers["use-compression"]) ?? true,
|
||||
})
|
||||
res.header("deprecated", "true")
|
||||
res.header(
|
||||
"deprecation-replacement",
|
||||
"Use the new chunked upload API endpoint",
|
||||
)
|
||||
|
||||
fs.promises.rm(tmpPath, { recursive: true, force: true })
|
||||
|
||||
return result
|
||||
}
|
||||
return result
|
||||
},
|
||||
}
|
||||
|
@ -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
|
||||
}
|
@ -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
|
@ -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)
|
||||
}
|
@ -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
|
@ -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
|
||||
}
|
@ -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,
|
||||
}
|
||||
}
|
@ -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,
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
@ -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()
|
||||
})
|
||||
}
|
@ -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)
|
||||
}
|
@ -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()
|
||||
}
|
@ -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
|
@ -7,9 +7,10 @@ import SharedMiddlewares from "@shared-middlewares"
|
||||
|
||||
export default class API extends Server {
|
||||
static refName = "main"
|
||||
static enableWebsockets = true
|
||||
static useEngine = "hyper-express-ng"
|
||||
static routesPath = `${__dirname}/routes`
|
||||
static listen_port = process.env.HTTP_LISTEN_PORT || 3000
|
||||
static enableWebsockets = false
|
||||
|
||||
middlewares = {
|
||||
...require("@middlewares").default,
|
||||
@ -26,8 +27,6 @@ export default class API extends Server {
|
||||
await this.contexts.db.initialize()
|
||||
await StartupDB()
|
||||
}
|
||||
|
||||
handleWsAuth = require("@shared-lib/handleWsAuth").default
|
||||
}
|
||||
|
||||
Boot(API)
|
||||
|
@ -1,7 +1,6 @@
|
||||
{
|
||||
"name": "main",
|
||||
"version": "0.60.2",
|
||||
"dependencies": {
|
||||
"@octokit/rest": "^20.0.2"
|
||||
}
|
||||
"name": "main",
|
||||
"dependencies": {
|
||||
"@octokit/rest": "^20.0.2"
|
||||
}
|
||||
}
|
||||
|
@ -1,24 +1,27 @@
|
||||
import { Extension } from "@db_models"
|
||||
|
||||
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) {
|
||||
pkgVersion = "latest"
|
||||
}
|
||||
if (!pkgVersion) {
|
||||
pkgVersion = "latest"
|
||||
}
|
||||
|
||||
if (pkgVersion === "latest") {
|
||||
return await Extension.findOne({
|
||||
user_id,
|
||||
name: pkgName,
|
||||
}).sort({ version: -1 }).limit(1).exec()
|
||||
}
|
||||
if (pkgVersion === "latest") {
|
||||
return await Extension.findOne({
|
||||
user_id,
|
||||
name: pkgName,
|
||||
})
|
||||
.sort({ version: -1 })
|
||||
.limit(1)
|
||||
.exec()
|
||||
}
|
||||
|
||||
return await Extension.findOne({
|
||||
user_id,
|
||||
name: pkgName,
|
||||
version: pkgVersion,
|
||||
})
|
||||
}
|
||||
return await Extension.findOne({
|
||||
user_id,
|
||||
name: pkgName,
|
||||
version: pkgVersion,
|
||||
})
|
||||
}
|
||||
|
@ -1,41 +1,47 @@
|
||||
import { Server } from "linebridge"
|
||||
import B2 from "backblaze-b2"
|
||||
|
||||
import DbManager from "@shared-classes/DbManager"
|
||||
import CacheService from "@shared-classes/CacheService"
|
||||
import StorageClient from "@shared-classes/StorageClient"
|
||||
|
||||
import SharedMiddlewares from "@shared-middlewares"
|
||||
|
||||
class API extends Server {
|
||||
static refName = "marketplace"
|
||||
static wsRoutesPath = `${__dirname}/ws_routes`
|
||||
static routesPath = `${__dirname}/routes`
|
||||
static listen_port = process.env.HTTP_LISTEN_PORT ?? 3005
|
||||
static refName = "marketplace"
|
||||
static useEngine = "hyper-express-ng"
|
||||
static routesPath = `${__dirname}/routes`
|
||||
static listen_port = process.env.HTTP_LISTEN_PORT ?? 3005
|
||||
|
||||
middlewares = {
|
||||
...SharedMiddlewares
|
||||
}
|
||||
middlewares = {
|
||||
...SharedMiddlewares,
|
||||
}
|
||||
|
||||
contexts = {
|
||||
db: new DbManager(),
|
||||
b2: new B2({
|
||||
applicationKeyId: process.env.B2_KEY_ID,
|
||||
applicationKey: process.env.B2_APP_KEY,
|
||||
}),
|
||||
cache: new CacheService({
|
||||
fsram: false
|
||||
}),
|
||||
}
|
||||
contexts = {
|
||||
db: new DbManager(),
|
||||
cache: new CacheService({
|
||||
fsram: false,
|
||||
}),
|
||||
storage: StorageClient({
|
||||
endPoint: process.env.B2_ENDPOINT,
|
||||
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() {
|
||||
await this.contexts.db.initialize()
|
||||
await this.contexts.b2.authorize()
|
||||
async onInitialize() {
|
||||
await this.contexts.db.initialize()
|
||||
await this.contexts.storage.initialize()
|
||||
|
||||
global.cache = this.contexts.cache
|
||||
global.b2 = this.contexts.b2
|
||||
}
|
||||
|
||||
handleWsAuth = require("@shared-lib/handleWsAuth").default
|
||||
global.cache = this.contexts.cache
|
||||
global.storages = {
|
||||
standard: this.contexts.storage,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Boot(API)
|
||||
Boot(API)
|
||||
|
@ -1,9 +1,6 @@
|
||||
{
|
||||
"name": "marketplace",
|
||||
"dependencies": {
|
||||
"7zip-min": "^1.4.4",
|
||||
"backblaze-b2": "^1.7.0",
|
||||
"sucrase": "^3.32.0",
|
||||
"uglify-js": "^3.17.4"
|
||||
"7zip-min": "^1.4.4"
|
||||
}
|
||||
}
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user