`
7 | display: inline-flex;
8 | align-items: center;
9 | font-size: 0.9rem;
10 | margin-top: 0.3rem;
11 | width: 100px;
12 |
13 | &::before {
14 | content: '';
15 | display: inline-block;
16 | width: 10px;
17 | height: 10px;
18 | border-radius: 50%;
19 | background: ${(p) => (p.available ? '#5fff5f' : '#ff2404')};
20 | margin-right: 0.5rem;
21 | }
22 | `;
23 |
24 | export const StorageName = styled.h3`
25 | margin: 0;
26 | font-size: 1.5rem;
27 | `;
28 |
29 | export const StorageHeader = styled.div`
30 | display: flex;
31 | flex-direction: row;
32 | justify-content: flex-start;
33 | align-items: center;
34 | `;
35 |
36 | export const Sizes = styled.div`
37 | margin-top: 0.5rem;
38 | font-size: 0.9rem;
39 | color: #aaa;
40 | `;
41 |
--------------------------------------------------------------------------------
/.github/workflows/dev.yml:
--------------------------------------------------------------------------------
1 | name: Publish a dev version
2 |
3 | on:
4 | push:
5 | branches: [ "develop" ]
6 |
7 | jobs:
8 | build:
9 | runs-on: ubuntu-latest
10 |
11 | steps:
12 | - uses: actions/checkout@v3
13 | - uses: docker/setup-buildx-action@v2
14 | - name: Login to Docker Hub
15 | uses: docker/login-action@v2
16 | with:
17 | username: ${{ secrets.DOCKERHUB_USERNAME }}
18 | password: ${{ secrets.DOCKERHUB_TOKEN }}
19 |
20 | - name: Get the version
21 | id: get_version
22 | uses: SebRollen/toml-action@v1.0.1
23 | with:
24 | file: 'backend/pyproject.toml'
25 | field: 'project.version'
26 |
27 | - name: Build docker image
28 | uses: docker/build-push-action@v4
29 | with:
30 | context: .
31 | cache-from: type=gha
32 | cache-to: type=gha,mode=max
33 | push: true
34 | tags: |
35 | nebulabroadcast/nebula-server:dev
36 |
--------------------------------------------------------------------------------
/backend/server/models/login.py:
--------------------------------------------------------------------------------
1 | from pydantic import Field
2 |
3 | from server.models import RequestModel, ResponseModel
4 |
5 |
6 | class LoginRequestModel(RequestModel):
7 | username: str = Field(
8 | ...,
9 | title="Username",
10 | examples=["admin"],
11 | pattern=r"^[a-zA-Z0-9_\-\.]{2,}$",
12 | )
13 | password: str = Field(
14 | ...,
15 | title="Password",
16 | description="Password in plain text",
17 | examples=["Password.123"],
18 | )
19 |
20 |
21 | class LoginResponseModel(ResponseModel):
22 | access_token: str = Field(
23 | ...,
24 | title="Access token",
25 | description="Access token to be used in Authorization header"
26 | "for the subsequent requests",
27 | )
28 |
29 |
30 | class TokenExchangeRequestModel(RequestModel):
31 | access_token: str = Field(
32 | ...,
33 | title="Access token",
34 | description="Access token to be exchanged for a new one",
35 | )
36 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | IMAGE_NAME=nebulabroadcast/nebula-server:dev
2 | VERSION=$(shell cd backend && uv run python -c 'import nebula' --version)
3 |
4 | check:
5 | cd frontend && \
6 | yarn format
7 |
8 | cd backend && \
9 | sed -i "s/^version = \".*\"/version = \"$(VERSION)\"/" pyproject.toml && \
10 | uv run ruff format . && \
11 | uv run ruff check --fix . && \
12 | uv run mypy .
13 |
14 | build:
15 | docker build -t $(IMAGE_NAME) .
16 |
17 | dist: build
18 | docker push $(IMAGE_NAME)
19 |
20 | setup-hooks:
21 | @echo "Setting up Git hooks..."
22 | @mkdir -p .git/hooks
23 | @echo '#!/bin/sh\n\n# Navigate to the repository root directory\ncd "$$(git rev-parse --show-toplevel)"\n\n# Execute the linting command from the Makefile\nmake check\n\n# Check the return code of the make command\nif [ $$? -ne 0 ]; then\n echo "Linting failed. Commit aborted."\n exit 1\nfi\n\n# If everything is fine, allow the commit\nexit 0' > .git/hooks/pre-commit
24 | @chmod +x .git/hooks/pre-commit
25 | @echo "Git hooks set up successfully."
26 |
--------------------------------------------------------------------------------
/frontend/src/components/table/HeaderCell.jsx:
--------------------------------------------------------------------------------
1 | import { useMemo } from 'react';
2 |
3 | const SortIcon = ({ children }) => (
4 | {children}
5 | );
6 |
7 | const HeaderCell = ({ name, width, title, sortDirection, onSort }) => {
8 | const sortArrowElement = useMemo(() => {
9 | if (!onSort) return;
10 | if (sortDirection === 'asc') return arrow_drop_up;
11 | if (sortDirection === 'desc') return arrow_drop_down;
12 | return more_vert;
13 | }, [sortDirection, onSort]);
14 |
15 | const onClick = () => {
16 | if (!onSort) return;
17 | if (sortDirection === 'asc') {
18 | onSort(name, 'desc');
19 | } else {
20 | onSort(name, 'asc');
21 | }
22 | };
23 | return (
24 | |
25 |
26 | {title}
27 | {sortArrowElement}
28 |
29 | |
30 | );
31 | };
32 |
33 | export default HeaderCell;
34 |
--------------------------------------------------------------------------------
/frontend/src/tableFormat/formatObjectTitle.jsx:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | import nebula from '/src/nebula';
4 |
5 | import { Spacer } from '../components';
6 |
7 | const TitleNote = styled.span`
8 | color: var(--color-text-dim);
9 | font-size: 0.8em;
10 | font-style: italic;
11 | `;
12 |
13 | const formatObjectTitle = (rowData, key) => {
14 | const title = rowData[key];
15 | const subtitle = rowData.subtitle;
16 | const note = rowData.note;
17 | const tstyle = {};
18 | if (rowData.is_primary) tstyle.fontWeight = 'bold';
19 | return (
20 |
21 |
22 | {title}
23 | {subtitle && (
24 |
25 | {nebula.settings.system.subtitle_separator}
26 | {subtitle}
27 |
28 | )}
29 | {note && }
30 | {note && {note}}
31 |
32 | |
33 | );
34 | };
35 |
36 | export default formatObjectTitle;
37 |
--------------------------------------------------------------------------------
/frontend/src/types/upload.ts:
--------------------------------------------------------------------------------
1 | export const UPLOAD_STATUS = {
2 | QUEUED: 'queued',
3 | UPLOADING: 'uploading',
4 | SUCCESS: 'success',
5 | ERROR: 'error',
6 | CANCELED: 'canceled',
7 | } as const;
8 |
9 | // Get the union of literal string values from UPLOAD_STATUS
10 | export type MediaUploadStatus = (typeof UPLOAD_STATUS)[keyof typeof UPLOAD_STATUS];
11 |
12 | // Define the shape of a single task in the upload queue
13 | export interface MediaUploadTask {
14 | id: string;
15 | title: string;
16 | file: File;
17 | status: MediaUploadStatus;
18 | progress: number;
19 | bytesTransferred: number;
20 | totalBytes: number;
21 | controller: AbortController; // Used for cancellation
22 | }
23 |
24 | // Define the shape of the context object returned by useUploadQueue
25 | export interface MediaUploadContextType {
26 | queue: MediaUploadTask[];
27 | addToQueue: (file: File, id: string, title: string) => void;
28 | cancelUpload: (id: string) => void;
29 | dismissTask: (id: string) => void;
30 | UPLOAD_STATUS: typeof UPLOAD_STATUS;
31 | }
32 |
--------------------------------------------------------------------------------
/backend/nx/utils/__init__.py:
--------------------------------------------------------------------------------
1 | __all__ = [
2 | "hash_data",
3 | "create_hash",
4 | "create_uuid",
5 | "camelize",
6 | "format_filesize",
7 | "get_base_name",
8 | "indent",
9 | "obscure",
10 | "slugify",
11 | "parse_access_token",
12 | "parse_api_key",
13 | "string2color",
14 | "fract2float",
15 | "unaccent",
16 | "datestr2ts",
17 | "tc2s",
18 | "s2time",
19 | "f2tc",
20 | "s2tc",
21 | "s2words",
22 | "format_time",
23 | "xml",
24 | ]
25 |
26 |
27 | from .hashing import (
28 | create_hash,
29 | create_uuid,
30 | hash_data,
31 | )
32 | from .strings import (
33 | camelize,
34 | format_filesize,
35 | fract2float,
36 | get_base_name,
37 | indent,
38 | obscure,
39 | parse_access_token,
40 | parse_api_key,
41 | slugify,
42 | string2color,
43 | unaccent,
44 | )
45 | from .timeutils import (
46 | datestr2ts,
47 | f2tc,
48 | format_time,
49 | s2tc,
50 | s2time,
51 | s2words,
52 | tc2s,
53 | )
54 | from .xml import xml
55 |
--------------------------------------------------------------------------------
/frontend/src/tableFormat/formatObjectDuration.jsx:
--------------------------------------------------------------------------------
1 | import { Timecode } from '@wfoxall/timeframe';
2 |
3 | const formatObjectDuration = (rowData, key) => {
4 | if (rowData.run_mode === 4) {
5 | return (
6 |
7 |
8 | |
9 | );
10 | }
11 |
12 | if (rowData.item_role === 'lead_in' || rowData.item_role === 'lead_out') {
13 | return (
14 |
15 |
16 | |
17 | );
18 | }
19 |
20 | const fps = rowData['video/fps_f'] || 25;
21 | let duration = rowData[key] || 0;
22 | if (rowData.mark_out) duration = rowData.mark_out;
23 | if (rowData.mark_in) duration -= rowData.mark_in;
24 | const trimmed = duration < rowData.duration;
25 | const timecode = new Timecode(duration * fps, fps);
26 | const title =
27 | trimmed && `Original duration ${new Timecode(rowData.duration * fps, fps)}`;
28 | return (
29 |
30 | {timecode.toString().substring(0, 11)}
31 | {trimmed && '*'}
32 | |
33 | );
34 | };
35 |
36 | export default formatObjectDuration;
37 |
--------------------------------------------------------------------------------
/backend/api/users/list_users_request.py:
--------------------------------------------------------------------------------
1 | from pydantic import Field
2 |
3 | import nebula
4 | from server.dependencies import CurrentUser
5 | from server.models import ResponseModel, UserModel
6 | from server.request import APIRequest
7 |
8 |
9 | class ListUsersResponseModel(ResponseModel):
10 | """Response model for listing users"""
11 |
12 | users: list[UserModel] = Field(..., title="List of users")
13 |
14 |
15 | class ListUsersRequest(APIRequest):
16 | """Get a list of users"""
17 |
18 | name = "list-users"
19 | title = "Get user list"
20 | response_model = ListUsersResponseModel
21 |
22 | async def handle(self, user: CurrentUser) -> ListUsersResponseModel:
23 | if not user.is_admin:
24 | raise nebula.ForbiddenException("You are not allowed to list users")
25 |
26 | query = "SELECT meta FROM users ORDER BY login ASC"
27 | users = []
28 | async for row in nebula.db.iterate(query):
29 | users.append(UserModel.from_meta(row["meta"]))
30 |
31 | return ListUsersResponseModel(users=users)
32 |
--------------------------------------------------------------------------------
/backend/server/utils.py:
--------------------------------------------------------------------------------
1 | import contextlib
2 | import ipaddress
3 |
4 |
5 | def parse_access_token(authorization: str) -> str | None:
6 | """Parse an authorization header value.
7 |
8 | Get a TOKEN from "Bearer TOKEN" and return a token
9 | string or None if the input value does not match
10 | the expected format (64 bytes string)
11 | """
12 | if (not authorization) or not isinstance(authorization, str):
13 | return None
14 | try:
15 | ttype, token = authorization.split()
16 | except ValueError:
17 | return None
18 | if ttype.lower() != "bearer":
19 | return None
20 | if len(token) != 64:
21 | return None
22 | return token
23 |
24 |
25 | def is_internal_ip(ip: str) -> bool:
26 | """Return true if the given IP address is private"""
27 | with contextlib.suppress(ValueError):
28 | if ipaddress.IPv4Address(ip).is_private:
29 | return True
30 |
31 | with contextlib.suppress(ValueError):
32 | if ipaddress.IPv6Address(ip).is_private:
33 | return True
34 | return False
35 |
--------------------------------------------------------------------------------
/frontend/src/index.tsx:
--------------------------------------------------------------------------------
1 | import { configureStore } from '@reduxjs/toolkit';
2 | import React from 'react';
3 | import { createRoot } from 'react-dom/client';
4 | import { Provider as ReduxProvider } from 'react-redux';
5 | import { ToastContainer, Flip } from 'react-toastify';
6 |
7 | import contextReducer from './actions';
8 | import App from './app';
9 |
10 | import 'react-toastify/dist/ReactToastify.css';
11 | import 'material-symbols';
12 | import './index.scss';
13 | import './datepicker.scss';
14 |
15 | const store = configureStore({
16 | reducer: {
17 | context: contextReducer,
18 | },
19 | });
20 |
21 | const root = createRoot(document.getElementById('root') as HTMLElement);
22 | root.render(
23 |
24 |
25 |
26 |
37 |
38 |
39 | );
40 |
--------------------------------------------------------------------------------
/frontend/src/containers/Calendar/CalendarWrapper.jsx:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | const CalendarWrapper = styled.div`
4 | display: flex;
5 | flex-grow: 1;
6 | flex-direction: column;
7 | font-family: Arial;
8 | gap: 6px;
9 |
10 | .calendar-header {
11 | user-select: none;
12 | user-drag: none;
13 | display: flex;
14 | margin-right: ${(props) =>
15 | props.scrollbarwidth}px; /* Dynamic padding to account for scrollbar */
16 | margin-left: ${(props) =>
17 | props.clockwidth}px; /* Dynamic padding to account for scrollbar */
18 |
19 | .calendar-day {
20 | flex: 1;
21 | display: flex;
22 | align-items: center;
23 | justify-content: center;
24 | padding: 6px 0;
25 | color: #c0c0c0;
26 | }
27 | }
28 |
29 | .calendar-body {
30 | display: flex;
31 | flex-grow: 1;
32 | position: relative;
33 |
34 | .calendar-body-wrapper {
35 | position: absolute;
36 | top: 0;
37 | left: 0;
38 | right: 0;
39 | bottom: 0;
40 | overflow-x: hidden;
41 | overflow-y: scroll;
42 | }
43 | }
44 | `;
45 | export default CalendarWrapper;
46 |
--------------------------------------------------------------------------------
/backend/api/order/order_request.py:
--------------------------------------------------------------------------------
1 | import nebula
2 | from nebula.helpers.scheduling import bin_refresh
3 | from server.dependencies import CurrentUser, RequestInitiator
4 | from server.request import APIRequest
5 |
6 | from .models import OrderRequestModel, OrderResponseModel
7 | from .set_rundown_order import set_rundown_order
8 |
9 |
10 | class OrderRequest(APIRequest):
11 | """Set the order of items of a rundown"""
12 |
13 | name = "order"
14 | title = "Order"
15 | response_model = OrderResponseModel
16 |
17 | async def handle(
18 | self,
19 | request: OrderRequestModel,
20 | user: CurrentUser,
21 | initiator: RequestInitiator,
22 | ) -> OrderResponseModel:
23 | if not user.can("rundown_edit", request.id_channel):
24 | raise nebula.ForbiddenException("You are not allowed to edit this rundown")
25 |
26 | result = await set_rundown_order(request, user)
27 | nebula.log.info(f"Changed order in bins {result.affected_bins}", user=user.name)
28 |
29 | # Update bin duration
30 | await bin_refresh(result.affected_bins, initiator=initiator, user=user)
31 | return result
32 |
--------------------------------------------------------------------------------
/backend/api/sessions/list_sessions_request.py:
--------------------------------------------------------------------------------
1 | from fastapi import Query
2 |
3 | import nebula
4 | from server.dependencies import CurrentUser
5 | from server.models import RequestModel
6 | from server.request import APIRequest
7 | from server.session import Session, SessionModel
8 |
9 |
10 | class ListSessionsRequestModel(RequestModel):
11 | id_user: int = Query(..., examples=[1])
12 |
13 |
14 | class ListSessionsRequest(APIRequest):
15 | """List user sessions."""
16 |
17 | name = "list-sessions"
18 | title = "List sessions"
19 |
20 | async def handle(
21 | self,
22 | request: ListSessionsRequestModel,
23 | user: CurrentUser,
24 | ) -> list[SessionModel]:
25 | id_user = request.id_user
26 |
27 | if id_user != user.id and (not user.is_admin):
28 | raise nebula.ForbiddenException()
29 |
30 | result = []
31 | async for session in Session.list():
32 | if (id_user is not None) and (id_user != session.user["id"]):
33 | continue
34 |
35 | if (not user.is_admin) and (id_user != session.user["id"]):
36 | continue
37 |
38 | result.append(session)
39 |
40 | return result
41 |
--------------------------------------------------------------------------------
/backend/api/auth/token_exchange.py:
--------------------------------------------------------------------------------
1 | from fastapi import Request
2 |
3 | import nebula
4 | from server.models.login import LoginResponseModel, TokenExchangeRequestModel
5 | from server.request import APIRequest
6 | from server.session import Session
7 |
8 |
9 | class TokenExchangeRequest(APIRequest):
10 | """Exachange a transient access token for a normal one
11 |
12 | This request will exchange an access token for a new one.
13 | The original access token will be invalidated.
14 | """
15 |
16 | name: str = "token-exchange"
17 | response_model = LoginResponseModel
18 |
19 | async def handle(
20 | self,
21 | request: Request,
22 | payload: TokenExchangeRequestModel,
23 | ) -> LoginResponseModel:
24 | session = await Session.check(payload.access_token, request, transient=True)
25 | if not session:
26 | raise nebula.UnauthorizedException("Invalid token")
27 | user_id = session.user["id"]
28 | user = await nebula.User.load(user_id)
29 | session = await Session.create(user, request)
30 | nebula.log.debug(f"{user} token exchanged")
31 | await Session.delete(payload.access_token)
32 | return LoginResponseModel(access_token=session.token)
33 |
--------------------------------------------------------------------------------
/frontend/src/components/InputText.tsx:
--------------------------------------------------------------------------------
1 | import { forwardRef } from 'react';
2 |
3 | import Input from './Input.styled';
4 |
5 | interface InputTextProps {
6 | value?: string;
7 | onChange: (value: string) => void;
8 | disabled?: boolean;
9 | placeholder?: string;
10 | tooltip?: string;
11 | style?: React.CSSProperties;
12 | className?: string;
13 | readOnly?: boolean;
14 | onDoubleClick?: (e: React.MouseEvent) => void;
15 | onKeyDown?: (e: React.KeyboardEvent) => void;
16 | }
17 |
18 | const InputText = forwardRef(
19 | (props: InputTextProps, ref) => {
20 | return (
21 | props.onChange(e.target.value)}
30 | onDoubleClick={props.onDoubleClick}
31 | onKeyDown={props.onKeyDown}
32 | style={props.style}
33 | className={props.className}
34 | />
35 | );
36 | }
37 | );
38 | InputText.displayName = 'InputText';
39 |
40 | export default InputText;
41 |
--------------------------------------------------------------------------------
/frontend/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "lib": ["DOM", "DOM.Iterable", "ESNext"],
5 | "module": "ESNext",
6 | "skipLibCheck": true,
7 |
8 | /* Bundler mode */
9 | "moduleResolution": "bundler",
10 | "allowImportingTsExtensions": true,
11 | "resolveJsonModule": true,
12 | "isolatedModules": true,
13 | "noEmit": true,
14 | "jsx": "react-jsx",
15 | "allowJs": true,
16 |
17 | /* Linting */
18 | "strict": true,
19 | "noUnusedLocals": true,
20 | "noUnusedParameters": true,
21 | "noFallthroughCasesInSwitch": true,
22 | "types": ["vite/client"],
23 | "baseUrl": "src",
24 | "paths": {
25 | "@actions": ["./actions"],
26 | "@actions/*": ["./actions/*"],
27 | "@components": ["./components"],
28 | "@components/*": ["./components/*"],
29 | "@containers": ["./containers"],
30 | "@containers/*": ["./containers/*"],
31 | "@hooks": ["./hooks"],
32 | "@hooks/*": ["./hooks/*"],
33 | "@types": ["./types"],
34 | "@": ["./"],
35 | "@/*": ["./*"]
36 | }
37 | },
38 | "include": ["src"],
39 | "exclude": ["node_modules", "dist"],
40 | "references": [{ "path": "./tsconfig.node.json" }]
41 | }
42 |
--------------------------------------------------------------------------------
/frontend/src/components/RangeSlider.tsx:
--------------------------------------------------------------------------------
1 | import React, { forwardRef } from 'react';
2 | import styled from 'styled-components';
3 |
4 | import { getTheme } from './theme';
5 |
6 | const StyledRange = styled.input`
7 | border: 0;
8 | border-radius: ${getTheme().inputBorderRadius};
9 | background: ${getTheme().inputBackground};
10 |
11 | -webkit-appearance: none;
12 | appearance: none;
13 | background: transparent;
14 |
15 | cursor: pointer;
16 | width: 150px;
17 | outline: none;
18 |
19 | &::-webkit-slider-thumb {
20 | -webkit-appearance: none;
21 | appearance: none;
22 | width: 14px;
23 | height: 14px;
24 | border-radius: 50%;
25 | background: ${getTheme().colors.surface08};
26 | }
27 |
28 | &::-webkit-slider-runnable-track {
29 | width: 100%;
30 | cursor: pointer;
31 | background: ${getTheme().colors.surface04};
32 | border-radius: 8px;
33 | }
34 | `;
35 |
36 | interface RangeSliderProps extends React.InputHTMLAttributes {}
37 |
38 | const RangeSlider = forwardRef((props, ref) => {
39 | return ;
40 | });
41 |
42 | RangeSlider.displayName = 'RangeSlider';
43 |
44 | export default RangeSlider;
45 |
--------------------------------------------------------------------------------
/frontend/src/pages/System/SystemPage.tsx:
--------------------------------------------------------------------------------
1 | import { Navbar, Spacer, Button } from '@components';
2 | import { useMemo } from 'react';
3 | import { useParams, NavLink } from 'react-router-dom';
4 |
5 | import Services from './Services';
6 | import Storages from './Storages';
7 | import Users from './Users';
8 |
9 | const SystemNav = () => {
10 | return (
11 |
12 | Services
13 | Storages
14 | Users
15 |
16 |
17 |
18 | );
19 | };
20 |
21 | const SystemPage = () => {
22 | const { view } = useParams();
23 |
24 | const pageComponent = useMemo(() => {
25 | switch (view) {
26 | case 'services':
27 | return ;
28 | case 'storages':
29 | return ;
30 | case 'users':
31 | return ;
32 | default:
33 | return Select a view from the navigation.
;
34 | }
35 | }, [view]);
36 |
37 | return (
38 |
39 |
40 | {pageComponent}
41 |
42 | );
43 | };
44 |
45 | export default SystemPage;
46 |
--------------------------------------------------------------------------------
/frontend/src/pages/AssetEditor/AssigneesButton.jsx:
--------------------------------------------------------------------------------
1 | import nebula from '/src/nebula';
2 |
3 | import { useMemo, useState } from 'react';
4 |
5 | import { Button } from '/src/components';
6 | import { SelectDialog } from '/src/components';
7 |
8 | const AssigneesButton = ({ assignees, setAssignees }) => {
9 | const [dialogVisible, setDialogVisible] = useState(false);
10 |
11 | const options = useMemo(() => {
12 | return nebula.settings.users.map((user) => {
13 | return {
14 | value: `${user.id}`,
15 | title: user.name,
16 | };
17 | });
18 | }, [nebula.settings.users]);
19 |
20 | return (
21 | <>
22 | {dialogVisible && (
23 | {
29 | setAssignees((value || []).map((v) => parseInt(v)));
30 | setDialogVisible(false);
31 | }}
32 | />
33 | )}
34 |