diff --git a/backend/src/index.js b/backend/src/index.js index d9aeae9..be99196 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -50,6 +50,7 @@ const upload_jobsRoutes = require('./routes/upload_jobs'); const upload_job_logsRoutes = require('./routes/upload_job_logs'); const release_schedulesRoutes = require('./routes/release_schedules'); +const youtubeReleaseConsoleRoutes = require('./routes/youtube_release_console'); const getBaseUrl = (url) => { @@ -137,6 +138,8 @@ app.use('/api/upload_job_logs', passport.authenticate('jwt', {session: false}), app.use('/api/release_schedules', passport.authenticate('jwt', {session: false}), release_schedulesRoutes); +app.use('/api/youtube-release-console', passport.authenticate('jwt', {session: false}), youtubeReleaseConsoleRoutes); + app.use( '/api/openai', passport.authenticate('jwt', { session: false }), diff --git a/backend/src/routes/youtube_release_console.js b/backend/src/routes/youtube_release_console.js new file mode 100644 index 0000000..0086b09 --- /dev/null +++ b/backend/src/routes/youtube_release_console.js @@ -0,0 +1,142 @@ +const express = require('express'); +const db = require('../db/models'); +const wrapAsync = require('../helpers').wrapAsync; +const { checkPermissions } = require('../middlewares/check-permissions'); + +const router = express.Router(); + +const VISIBILITY_VALUES = ['private', 'unlisted', 'public']; +const CONTENT_TYPE_VALUES = ['music_video', 'lyric_video', 'visualizer', 'audio']; +const JOB_TYPE_BY_CONTENT_TYPE = { + music_video: 'upload_video', + lyric_video: 'upload_video', + visualizer: 'upload_video', + audio: 'upload_audio_with_static_image', +}; + +const cleanText = (value) => (typeof value === 'string' ? value.trim() : ''); + +const ensureEnum = (value, allowed, fallback) => (allowed.includes(value) ? value : fallback); + +router.use(checkPermissions('CREATE_UPLOAD_JOBS')); + +router.post('/draft-upload', wrapAsync(async (req, res) => { + const data = req.body?.data || {}; + const currentUser = req.currentUser; + + const trackTitle = cleanText(data.track_title); + const youtubeTitle = cleanText(data.youtube_title) || trackTitle; + const youtubeAccountId = cleanText(data.youtube_account_id); + const contentType = ensureEnum(data.content_type, CONTENT_TYPE_VALUES, 'music_video'); + const privacyStatus = ensureEnum(data.privacy_status, VISIBILITY_VALUES, 'private'); + + const validationErrors = []; + + if (!trackTitle) validationErrors.push('Track title is required.'); + if (!youtubeTitle) validationErrors.push('YouTube title is required.'); + if (!youtubeAccountId) validationErrors.push('A YouTube account/channel is required.'); + + const youtubeAccount = youtubeAccountId + ? await db.youtube_accounts.findOne({ where: { id: youtubeAccountId } }) + : null; + + if (youtubeAccountId && !youtubeAccount) { + validationErrors.push('Selected YouTube account was not found.'); + } + + if (validationErrors.length) { + res.status(400).send({ errors: validationErrors }); + return; + } + + const organizationId = currentUser?.organizations?.id || currentUser?.organizationsId || null; + const now = new Date(); + + const result = await db.sequelize.transaction(async (transaction) => { + const track = await db.tracks.create({ + title: trackTitle, + track_number: data.track_number ? Number(data.track_number) : null, + isrc: cleanText(data.isrc) || null, + duration_seconds: data.duration_seconds ? Number(data.duration_seconds) : null, + content_type: contentType, + language: cleanText(data.language) || 'en', + explicit: Boolean(data.explicit), + version: cleanText(data.version) || null, + description: cleanText(data.description) || null, + status: 'ready_for_upload', + albumId: cleanText(data.album_id) || null, + artistId: cleanText(data.artist_id) || null, + organizationsId: organizationId, + createdById: currentUser.id, + updatedById: currentUser.id, + }, { transaction }); + + const asset = await db.youtube_video_assets.create({ + title: youtubeTitle, + description: cleanText(data.youtube_description) || cleanText(data.description) || null, + tags_csv: cleanText(data.tags_csv) || null, + category: 'music', + privacy_status: privacyStatus, + made_for_kids: Boolean(data.made_for_kids), + scheduled_publish_at: data.scheduled_publish_at || null, + trackId: track.id, + youtube_accountId: youtubeAccount.id, + organizationsId: organizationId, + createdById: currentUser.id, + updatedById: currentUser.id, + }, { transaction }); + + const job = await db.upload_jobs.create({ + job_type: JOB_TYPE_BY_CONTENT_TYPE[contentType], + status: 'queued', + attempt_count: 0, + max_attempts: 3, + queued_at: now, + progress_percent: 0, + error_code: null, + error_message: null, + provider_request_identifier: `yt-draft-${now.getTime()}`, + youtube_accountId: youtubeAccount.id, + trackId: track.id, + youtube_video_assetId: asset.id, + requested_by_userId: currentUser.id, + organizationsId: organizationId, + createdById: currentUser.id, + updatedById: currentUser.id, + }, { transaction }); + + await db.upload_job_logs.create({ + logged_at: now, + level: 'info', + message: 'Upload package created and queued for YouTube publishing review.', + details_json: JSON.stringify({ + youtubeTitle, + privacyStatus, + channel: youtubeAccount.channel_title || youtubeAccount.account_name, + }), + upload_jobId: job.id, + organizationsId: organizationId, + createdById: currentUser.id, + updatedById: currentUser.id, + }, { transaction }); + + return { + track: { id: track.id, title: track.title }, + youtube_video_asset: { id: asset.id, title: asset.title, privacy_status: asset.privacy_status }, + upload_job: { id: job.id, status: job.status, job_type: job.job_type, queued_at: job.queued_at }, + youtube_account: { + id: youtubeAccount.id, + account_name: youtubeAccount.account_name, + channel_title: youtubeAccount.channel_title, + channel_handle: youtubeAccount.channel_handle, + auth_status: youtubeAccount.auth_status, + }, + }; + }); + + res.status(201).send(result); +})); + +router.use('/', require('../helpers').commonErrorHandler); + +module.exports = router; diff --git a/frontend/src/components/AsideMenuLayer.tsx b/frontend/src/components/AsideMenuLayer.tsx index 3529da5..3a98deb 100644 --- a/frontend/src/components/AsideMenuLayer.tsx +++ b/frontend/src/components/AsideMenuLayer.tsx @@ -3,10 +3,9 @@ import { mdiLogout, mdiClose } from '@mdi/js' import BaseIcon from './BaseIcon' import AsideMenuList from './AsideMenuList' import { MenuAsideItem } from '../interfaces' -import { useAppSelector } from '../stores/hooks' +import { useAppDispatch, useAppSelector } from '../stores/hooks' import Link from 'next/link'; -import { useAppDispatch } from '../stores/hooks'; import { createAsyncThunk } from '@reduxjs/toolkit'; import axios from 'axios'; diff --git a/frontend/src/components/NavBarItem.tsx b/frontend/src/components/NavBarItem.tsx index eb155e3..fb0fca2 100644 --- a/frontend/src/components/NavBarItem.tsx +++ b/frontend/src/components/NavBarItem.tsx @@ -1,6 +1,5 @@ -import React, {useEffect, useRef} from 'react' +import React, { useEffect, useRef, useState } from 'react' import Link from 'next/link' -import { useState } from 'react' import { mdiChevronUp, mdiChevronDown } from '@mdi/js' import BaseDivider from './BaseDivider' import BaseIcon from './BaseIcon' diff --git a/frontend/src/layouts/Authenticated.tsx b/frontend/src/layouts/Authenticated.tsx index 1b9907d..73d8391 100644 --- a/frontend/src/layouts/Authenticated.tsx +++ b/frontend/src/layouts/Authenticated.tsx @@ -1,5 +1,4 @@ -import React, { ReactNode, useEffect } from 'react' -import { useState } from 'react' +import React, { ReactNode, useEffect, useState } from 'react' import jwt from 'jsonwebtoken'; import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js' import menuAside from '../menuAside' diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts index 18570af..26560e7 100644 --- a/frontend/src/menuAside.ts +++ b/frontend/src/menuAside.ts @@ -56,6 +56,14 @@ const menuAside: MenuAsideItem[] = [ icon: 'mdiMicrophoneVariant' in icon ? icon['mdiMicrophoneVariant' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable, permissions: 'READ_ARTISTS' }, + { + href: '/youtube_uploader/release-console', + label: 'Release Console', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: 'mdiYoutubeStudio' in icon ? icon['mdiYoutubeStudio' as keyof typeof icon] : icon.mdiYoutube ?? icon.mdiTable, + permissions: 'CREATE_UPLOAD_JOBS' + }, { href: '/youtube_accounts/youtube_accounts-list', label: 'Youtube accounts', diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx index d0401b2..622e2e8 100644 --- a/frontend/src/pages/index.tsx +++ b/frontend/src/pages/index.tsx @@ -1,166 +1,129 @@ +import { mdiAlbum, mdiChartTimelineVariant, mdiCloudUploadOutline, mdiShieldCheckOutline, mdiYoutube } from '@mdi/js' +import Head from 'next/head' +import Link from 'next/link' +import React, { ReactElement } from 'react' +import BaseButton from '../components/BaseButton' +import BaseIcon from '../components/BaseIcon' +import CardBox from '../components/CardBox' +import { getPageTitle } from '../config' +import LayoutGuest from '../layouts/Guest' -import React, { useEffect, useState } from 'react'; -import type { ReactElement } from 'react'; -import Head from 'next/head'; -import Link from 'next/link'; -import BaseButton from '../components/BaseButton'; -import CardBox from '../components/CardBox'; -import SectionFullScreen from '../components/SectionFullScreen'; -import LayoutGuest from '../layouts/Guest'; -import BaseDivider from '../components/BaseDivider'; -import BaseButtons from '../components/BaseButtons'; -import { getPageTitle } from '../config'; -import { useAppSelector } from '../stores/hooks'; -import CardBoxComponentTitle from "../components/CardBoxComponentTitle"; -import { getPexelsImage, getPexelsVideo } from '../helpers/pexels'; +const features = [ + { + title: 'Channel-ready upload queue', + description: 'Stage YouTube titles, descriptions, tags, privacy, schedule, and publishing status before an API worker sends the release.', + icon: mdiCloudUploadOutline, + }, + { + title: 'Artist → Album → Track catalog', + description: 'Keep release metadata organized around artists, albums, tracks, video assets, and operational upload logs.', + icon: mdiAlbum, + }, + { + title: 'Admin controls by default', + description: 'Use authenticated team access, roles, permissions, audit history, and retry-friendly job records.', + icon: mdiShieldCheckOutline, + }, +] +const workflow = ['Connect channel', 'Prepare metadata', 'Queue upload', 'Review status'] export default function Starter() { - const [illustrationImage, setIllustrationImage] = useState({ - src: undefined, - photographer: undefined, - photographer_url: undefined, - }) - const [illustrationVideo, setIllustrationVideo] = useState({video_files: []}) - const [contentType, setContentType] = useState('image'); - const [contentPosition, setContentPosition] = useState('right'); - const textColor = useAppSelector((state) => state.style.linkColor); - - const title = 'Vevo Style Uploader' - - // Fetch Pexels image/video - useEffect(() => { - async function fetchData() { - const image = await getPexelsImage(); - const video = await getPexelsVideo(); - setIllustrationImage(image); - setIllustrationVideo(video); - } - fetchData(); - }, []); - - const imageBlock = (image) => ( -
-
- - Photo by {image?.photographer} on Pexels - -
-
- ); - - const videoBlock = (video) => { - if (video?.video_files?.length > 0) { - return ( -
- -
- - Video by {video.user.name} on Pexels - -
-
) - } - }; + const title = 'Vevo Style Uploader' return ( -
+
- {getPageTitle('Starter Page')} + {getPageTitle('YouTube Music Distribution')} + - -
- {contentType === 'image' && contentPosition !== 'background' - ? imageBlock(illustrationImage) - : null} - {contentType === 'video' && contentPosition !== 'background' - ? videoBlock(illustrationVideo) - : null} -
- - - -
-

This is a React.js/Node.js app generated by the Flatlogic Web App Generator

-

For guides and documentation please check - your local README.md and the Flatlogic documentation

-
- - - - - -
-
-
-
-
-

© 2026 {title}. All rights reserved

- - Privacy Policy +
+ + + + + + VEVO-style + {title} + -
+ + +
+
+
+
+
+
+ YouTube music distribution MVP +
+

Upload music releases like a modern label ops team.

+

+ A polished admin workflow for preparing artists, albums, tracks, music videos, and YouTube upload jobs—built for a Vevo-style channel pipeline. +

+
+ + +
+

Admin access is required for the release console. The login link remains available in the header.

+
+ +
+
+
+
+

Release package

+

Official Video Upload

+
+ Queued +
+
+ {workflow.map((item, index) => ( +
+ {index + 1} +
+

{item}

+

{index === 0 ? '@YourLabelVEVO' : index === 1 ? 'Title, tags, thumbnail, schedule' : index === 2 ? 'Upload job + audit log' : 'Errors, retries, history'}

+
+
+ ))} +
+
+
+
+
+ +
+ {features.map((feature) => ( + + + + +

{feature.title}

+

{feature.description}

+
+ ))} +
+
+ +
+
+

© 2026 {title}. All rights reserved.

+
+ Privacy Policy + Admin Login +
+
+
- ); + ) } Starter.getLayout = function getLayout(page: ReactElement) { - return {page}; -}; - + return {page} +} diff --git a/frontend/src/pages/search.tsx b/frontend/src/pages/search.tsx index 00f5168..005eb07 100644 --- a/frontend/src/pages/search.tsx +++ b/frontend/src/pages/search.tsx @@ -1,9 +1,7 @@ import React, { ReactElement, useEffect, useState } from 'react'; import Head from 'next/head'; import 'react-datepicker/dist/react-datepicker.css'; -import { useAppDispatch } from '../stores/hooks'; - -import { useAppSelector } from '../stores/hooks'; +import { useAppDispatch, useAppSelector } from '../stores/hooks'; import { useRouter } from 'next/router'; import LayoutAuthenticated from '../layouts/Authenticated'; diff --git a/frontend/src/pages/youtube_uploader/release-console.tsx b/frontend/src/pages/youtube_uploader/release-console.tsx new file mode 100644 index 0000000..4e59d32 --- /dev/null +++ b/frontend/src/pages/youtube_uploader/release-console.tsx @@ -0,0 +1,415 @@ +import { + mdiAlbum, + mdiAlertCircleOutline, + mdiCheckCircleOutline, + mdiCloudUploadOutline, + mdiMusicNote, + mdiPlus, + mdiRefresh, + mdiYoutube, +} from '@mdi/js' +import axios from 'axios' +import Head from 'next/head' +import Link from 'next/link' +import React, { ReactElement, useEffect, useMemo, useState } from 'react' +import BaseButton from '../../components/BaseButton' +import BaseButtons from '../../components/BaseButtons' +import BaseIcon from '../../components/BaseIcon' +import CardBox from '../../components/CardBox' +import FormField from '../../components/FormField' +import NotificationBar from '../../components/NotificationBar' +import SectionMain from '../../components/SectionMain' +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton' +import { getPageTitle } from '../../config' +import LayoutAuthenticated from '../../layouts/Authenticated' +import { useAppSelector } from '../../stores/hooks' + +type OptionRecord = { + id: string + title?: string + name?: string + account_name?: string + channel_title?: string + channel_handle?: string + auth_status?: string +} + +type UploadJob = { + id: string + status?: string + job_type?: string + queued_at?: string + provider_request_identifier?: string + youtube_account?: OptionRecord + track?: OptionRecord + youtube_video_asset?: OptionRecord +} + +type FormState = { + youtube_account_id: string + artist_id: string + album_id: string + track_title: string + youtube_title: string + youtube_description: string + tags_csv: string + content_type: string + privacy_status: string + scheduled_publish_at: string + isrc: string + duration_seconds: string + explicit: boolean +} + +const initialForm: FormState = { + youtube_account_id: '', + artist_id: '', + album_id: '', + track_title: '', + youtube_title: '', + youtube_description: '', + tags_csv: '', + content_type: 'music_video', + privacy_status: 'private', + scheduled_publish_at: '', + isrc: '', + duration_seconds: '', + explicit: false, +} + +const statusStyles = { + queued: 'bg-amber-100 text-amber-800 ring-amber-200', + running: 'bg-blue-100 text-blue-800 ring-blue-200', + succeeded: 'bg-emerald-100 text-emerald-800 ring-emerald-200', + failed: 'bg-red-100 text-red-800 ring-red-200', + canceled: 'bg-slate-100 text-slate-700 ring-slate-200', + retrying: 'bg-purple-100 text-purple-800 ring-purple-200', +} + +const inputClass = 'w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm shadow-sm outline-none transition focus:border-red-400 focus:ring-2 focus:ring-red-100 dark:border-dark-700 dark:bg-dark-900' + +const labelFor = (item?: OptionRecord) => item?.title || item?.name || item?.channel_title || item?.account_name || 'Untitled' + +const ReleaseConsolePage = () => { + const { currentUser } = useAppSelector((state) => state.auth) + const [form, setForm] = useState(initialForm) + const [youtubeAccounts, setYoutubeAccounts] = useState([]) + const [artists, setArtists] = useState([]) + const [albums, setAlbums] = useState([]) + const [jobs, setJobs] = useState([]) + const [loadingData, setLoadingData] = useState(true) + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState('') + const [success, setSuccess] = useState(null) + + const defaultAccount = useMemo( + () => youtubeAccounts.find((account) => account.auth_status === 'connected') || youtubeAccounts[0], + [youtubeAccounts] + ) + + const selectedAccount = youtubeAccounts.find((account) => account.id === form.youtube_account_id) + + const loadData = async () => { + setLoadingData(true) + setError('') + try { + const [accountsResponse, artistsResponse, albumsResponse, jobsResponse] = await Promise.all([ + axios.get('/youtube_accounts?limit=100&page=0'), + axios.get('/artists?limit=100&page=0'), + axios.get('/albums?limit=100&page=0'), + axios.get('/upload_jobs?limit=6&page=0'), + ]) + + const accounts = accountsResponse.data?.rows || [] + setYoutubeAccounts(accounts) + setArtists(artistsResponse.data?.rows || []) + setAlbums(albumsResponse.data?.rows || []) + setJobs(jobsResponse.data?.rows || []) + + if (!form.youtube_account_id && accounts.length) { + const connected = accounts.find((account) => account.auth_status === 'connected') || accounts[0] + setForm((current) => ({ ...current, youtube_account_id: connected.id })) + } + } catch (loadError) { + console.error('Failed to load YouTube release console data:', loadError) + setError('Could not load catalog data. Please refresh or check your permissions.') + } finally { + setLoadingData(false) + } + } + + useEffect(() => { + if (!currentUser) return + loadData().then() + }, [currentUser]) + + const updateField = (field: keyof FormState, value: string | boolean) => { + setForm((current) => ({ ...current, [field]: value })) + } + + const validate = () => { + const messages = [] + if (!form.youtube_account_id) messages.push('Choose a YouTube channel.') + if (!form.track_title.trim()) messages.push('Add a track or video title.') + if (!form.youtube_title.trim()) messages.push('Add the public YouTube title.') + if (form.duration_seconds && Number(form.duration_seconds) <= 0) messages.push('Duration must be greater than 0 seconds.') + return messages + } + + const submitDraft = async (event: React.FormEvent) => { + event.preventDefault() + setError('') + setSuccess(null) + + const validationMessages = validate() + if (validationMessages.length) { + setError(validationMessages.join(' ')) + return + } + + setSubmitting(true) + try { + const response = await axios.post('/youtube-release-console/draft-upload', { data: form }) + setSuccess(response.data) + setForm({ ...initialForm, youtube_account_id: form.youtube_account_id }) + await loadData() + } catch (submitError) { + console.error('Failed to create YouTube upload package:', submitError) + const messages = submitError?.response?.data?.errors + setError(Array.isArray(messages) ? messages.join(' ') : 'Upload package could not be queued. Please review the form and try again.') + } finally { + setSubmitting(false) + } + } + + const healthItems = [ + { label: 'Channel', value: selectedAccount ? labelFor(selectedAccount) : 'Not selected', icon: mdiYoutube }, + { label: 'Catalog', value: `${artists.length} artists · ${albums.length} albums`, icon: mdiAlbum }, + { label: 'Queue', value: `${jobs.length} recent jobs`, icon: mdiCloudUploadOutline }, + ] + + return ( + <> + + {getPageTitle('YouTube Release Console')} + + + + + + +
+
+
+
+ VEVO-style release ops +
+

Stage music videos, validate metadata, and queue YouTube publishing jobs.

+

+ This first workflow creates a catalog track, YouTube video asset, upload job, and audit log in one guided pass. The actual YouTube API handoff can attach to this queue next. +

+
+ + +
+
+
+ {healthItems.map((item) => ( +
+
+ + + +
+

{item.label}

+

{item.value}

+
+
+
+ ))} +
+
+
+ + {error && ( + + {error} + + )} + + {success && ( + + Upload package queued. Open the{' '} + + job detail + {' '} + or review the{' '} + + YouTube asset + + . + + )} + +
+ +
+
+
+

Create upload package

+

Release metadata

+
+ Step 1 of YouTube pipeline +
+ + {loadingData ? ( +
Loading channels and catalog…
+ ) : ( + <> +
+ + + + + + + + + + + + +
+ + {!youtubeAccounts.length && ( +
+ No YouTube accounts exist yet. Create one in YouTube accounts before queueing a package. +
+ )} + +
+ + updateField('track_title', event.target.value)} placeholder='Example: Midnight Drive' /> + + + updateField('youtube_title', event.target.value)} placeholder='Artist - Midnight Drive (Official Video)' /> + + + updateField('isrc', event.target.value)} placeholder='US-XXX-26-00001' /> + + + updateField('duration_seconds', event.target.value)} placeholder='214' /> + +
+ + +