Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Integration of Owncast with MediaCMS for live streaming #763

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 39 additions & 7 deletions docs/admins_docs.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,10 @@
- [12. Video transcoding](#12-video-transcoding)
- [13. How To Add A Static Page To The Sidebar](#13-how-to-add-a-static-page-to-the-sidebar)
- [14. Add Google Analytics](#14-add-google-analytics)
- [15. Debugging email issues](#15-debugging-email-issues)
- [16. Frequently Asked Questions](#16-frequently-asked-questions)
- [17. Cookie consent code](#17-cookie-consent-code)
- [15. Connect to a live streaming server](#15-connect-to-a-live-streaming-server)
- [16. Debugging email issues](#15-debugging-email-issues)
- [17. Frequently Asked Questions](#16-frequently-asked-questions)
- [18. Cookie consent code](#17-cookie-consent-code)


## 1. Welcome
Expand Down Expand Up @@ -443,6 +444,20 @@ ADMINS_NOTIFICATIONS = {
- Make the portal workflow public, but at the same time set `GLOBAL_LOGIN_REQUIRED = True` so that only logged in users can see content.
- You can either set `REGISTER_ALLOWED = False` if you want to add members yourself or checkout options on "django-allauth settings" that affects registration in `cms/settings.py`. Eg set the portal invite only, or set email confirmation as mandatory, so that you control who registers.

### 5.24 Connect to a live streaming server (Owncast, etc.)

Choose which live streaming backend to use - at the moment, only Owncast is supported

```
LIVESTREAM_BACKEND = 'owncast'
```

Set the base address of the live streaming server

```
LIVESTREAM_URI = 'https://live.example.com/'
```

## 6. Manage pages
to be written

Expand Down Expand Up @@ -657,7 +672,24 @@ Instructions contributed by @alberto98fx

```

## 15. Debugging email issues
## 15. Connect to a live streaming server

MediaCMS can be integrated with a self-hosted live streaming server using Owncast. This allows you to display a link to a live stream at the top of the MediaCMS homepage as if it was a regular video, and it shows a Live button in the left nav menu which, when properly set up, also shows whether the stream is live or offline. Please note that because of the way Owncast works, there is only one Owncast installation per MediaCMS and thus you can only have one live stream at a time, and the live streams are not automatically uploaded into MediaCMS.

1. Download, install, and configure Owncast. Setup of Owncast is outside the scope of this guide, but the Owncast documentation has very good directions at [Owncast Quickstart](https://owncast.online/quickstart/)

2. Test out Owncast to make sure that your streaming software is properly set up to stream to it and that you can watch it as a viewer in a browser. If this doesn't work, the tie-in to MediaCMS will not work, either.

3. Add these two configuration lines as in [Section 5. Configuration](#5-configuration), customized with the address for your Owncast instance:

```
LIVESTREAM_BACKEND = 'owncast'
LIVESTREAM_URI = 'https://live.example.com/'
```

4. Restart MediaCMS

## 16. Debugging email issues
On the [Configuration](https://github.com/mediacms-io/mediacms/blob/main/docs/admins_docs.md#5-configuration) section of this guide we've see how to edit the email settings.
In case we are yet unable to receive email from MediaCMS, the following may help us debug the issue - in most cases it is an issue of setting the correct username, password or TLS option

Expand Down Expand Up @@ -692,7 +724,7 @@ For example, while specifying wrong password for my Gmail account I get
SMTPAuthenticationError: (535, b'5.7.8 Username and Password not accepted. Learn more at\n5.7.8 https://support.google.com/mail/?p=BadCredentials d4sm12687785wrc.34 - gsmtp')
```

## 16. Frequently Asked Questions
## 17. Frequently Asked Questions
Video is playing but preview thumbnails are not showing for large video files

Chances are that the sprites file was not created correctly.
Expand Down Expand Up @@ -731,7 +763,7 @@ In [3]: for media in Media.objects.filter(media_type='video', sprites=''):
this will re-create the sprites for videos that the task failed.


## 17. Cookie consent code
## 18. Cookie consent code
On file `templates/components/header.html` you can find a simple cookie consent code. It is commented, so you have to remove the `{% comment %}` and `{% endcomment %}` lines in order to enable it. Or you can replace that part with your own code that handles cookie consent banners.

![Simple Cookie Consent](images/cookie_consent.png)
![Simple Cookie Consent](images/cookie_consent.png)
2 changes: 2 additions & 0 deletions files/context_processors.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,6 @@ def stuff(request):
ret["ALLOW_RATINGS_CONFIRMED_EMAIL_ONLY"] = settings.ALLOW_RATINGS_CONFIRMED_EMAIL_ONLY
ret["VIDEO_PLAYER_FEATURED_VIDEO_ON_INDEX_PAGE"] = settings.VIDEO_PLAYER_FEATURED_VIDEO_ON_INDEX_PAGE
ret["RSS_URL"] = "/rss"
ret["LIVESTREAM_BACKEND"] = settings.LIVESTREAM_BACKEND
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you're not adding a default value on settings, so this will make the process break with

AttributeError: 'Settings' object has no attribute 'LIVESTREAM_BACKEND'

Better wrap this in a try/except to ensure that it does not break

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in latest commit

ret["LIVESTREAM_URI"] = settings.LIVESTREAM_URI
return ret
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import { PageStore } from '../../utils/stores/';
import { useLayout, useItemListInlineSlider } from '../../utils/hooks/';
import { ItemListAsync } from './ItemListAsync';
Expand Down Expand Up @@ -37,7 +38,8 @@ export function InlineSliderItemListAsync(props) {
props.firstItemRequestUrl,
props.requestUrl,
onItemsCount,
onItemsLoad
onItemsLoad,
props.translateCallback
)
);

Expand Down Expand Up @@ -74,9 +76,11 @@ export function InlineSliderItemListAsync(props) {

InlineSliderItemListAsync.propTypes = {
...ItemListAsync.propTypes,
translateCallback: PropTypes.func,
};

InlineSliderItemListAsync.defaultProps = {
...ItemListAsync.defaultProps,
pageItems: 12,
translateCallback: null,
};
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ export function ItemsListHandler(
first_item_request_url,
request_url,
itemsCountCallback,
loadItemsCallback
loadItemsCallback,
translateCallback
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand what translateCallback is doing, can you help me explain these changes?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The idea with this is to keep the frontend as unmodified as possible. The way it does this is by having the OwnCast query's response translated into the same format as the MediaCMS server would normally return for a video item.

) {
const config = {
maxItems: maxItems || 255,
Expand Down Expand Up @@ -97,6 +98,10 @@ export function ItemsListHandler(
function fn(response) {
waiting.requestResponse = false;

if (translateCallback) {
response = translateCallback(response);
}

if (!!!response || !!!response.data) {
return;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ export function MediaItemVideo(props) {
}

function playlistOptionsComponent() {
if (props.link.indexOf('=') < 0) return null

let mediaId = props.link.split('=')[1];
mediaId = mediaId.split('&')[0];
return props.hidePlaylistOptions ? null : (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useUser } from '../../../utils/hooks/';
import { PageStore } from '../../../utils/stores/';
import { LinksContext, SidebarContext } from '../../../utils/contexts/';
import { NavigationMenuList } from '../../_shared';
import { getRequest } from '../../../utils/helpers';

export function SidebarNavigationMenu() {
const { userCan, isAnonymous, pages: userPages } = useUser();
Expand All @@ -13,23 +14,25 @@ export function SidebarNavigationMenu() {

const currentUrl = urlParse(window.location.href);
const currentHostPath = (currentUrl.host + currentUrl.pathname).replace(/\/+$/, '');

var livePingInterval = 0;

function formatItems(items) {
return items.map((item) => {
const url = urlParse(item.link);
const active = currentHostPath === url.host + url.pathname;

return {
return Object.assign(item, {
active,
itemType: 'link',
link: item.link || '#',
icon: item.icon || null,
iconPos: 'left',
text: item.text || item.link || '#',
itemAttr: {
itemAttr: Object.assign(item.itemAttr || {}, {
className: item.className || '',
},
};
}),
});
});
}

Expand All @@ -44,6 +47,53 @@ export function SidebarNavigationMenu() {
className: 'nav-item-home',
});
}

if (window.MediaCMS && window.MediaCMS.site && window.MediaCMS.site.livestream && window.MediaCMS.site.livestream.uri) {
items.push({
link: window.MediaCMS.site.livestream.uri,
icon: 'radio_button_unchecked',
text: 'Live',
className: 'nav-item-live',
itemAttr: {
id: 'nav-item-live'
},
linkAttr: {
target: '_blank'
}
});
const pingFunc = () => {
switch (window.MediaCMS.site.livestream.backend || null) {
case 'owncast': {
getRequest(window.MediaCMS.site.livestream.uri + "/api/status", !1, (response) => {
const json = response.data;
const menuItem = document.getElementById('nav-item-live');
var onlineText = '(Offline)';
var dataIcon = 'radio_button_unchecked';
if (json && json.online) {
onlineText = '(Online)';
dataIcon = 'radio_button_checked';
}
// Change the icon to show it's live.
const icons = menuItem.getElementsByClassName('material-icons');
if (icons) {
icons[0].setAttribute('data-icon', dataIcon);
}
const spans = menuItem.getElementsByTagName('span');
const lastSpan = spans[spans.length - 1];
const smalls = lastSpan.getElementsByTagName('small');
for (var s = 0; s < smalls.length; ++s) {
smalls[s].parentNode.removeChild(smalls[s]);
}
lastSpan.appendChild(document.createElement('small')).appendChild(document.createTextNode(' ' + onlineText));
}, () => {});
} break;
}
};
if (!livePingInterval) {
pingFunc();
livePingInterval = setInterval(pingFunc, 60000);
}
}

if (PageStore.get('config-enabled').pages.featured && PageStore.get('config-enabled').pages.featured.enabled) {
items.push({
Expand Down
95 changes: 95 additions & 0 deletions frontend/src/static/js/pages/HomePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,38 @@ interface HomePageProps {
recommended_view_all_link: boolean;
}

interface TranslatedMediaObject {
friendly_token: string;
url: string;
api_url: string;
user: string;
title: string,
description: string;
add_date: string;
views: number;
media_type: string;
state: string;
duration: number;
thumbnail_url: string;
is_reviewed: boolean;
author_name: string;
author_profile: string;
author_thumbnail: string;
encoding_status: string;
likes: number;
dislikes: number;
featured: boolean;
user_featured: boolean;
size: string;
}

interface TranslatedResponse {
count: number;
next: string | null;
previous: string | null;
results: TranslatedMediaObject[];
}

export const HomePage: React.FC<HomePageProps> = ({
id = 'home',
featured_title = PageStore.get('config-options').pages.home.sections.featured.title,
Expand All @@ -45,12 +77,58 @@ export const HomePage: React.FC<HomePageProps> = ({
const [zeroMedia, setZeroMedia] = useState(false);
const [visibleLatest, setVisibleLatest] = useState(false);
const [visibleFeatured, setVisibleFeatured] = useState(false);
const [visibleLive, setVisibleLive] = useState(false);
const [visibleRecommended, setVisibleRecommended] = useState(false);

const onLoadLatest = (length: number) => {
setVisibleLatest(0 < length);
setZeroMedia(0 === length);
};

const translateLive = (response: any): object => {
let translatedResponse:TranslatedResponse = {
count: 0,
next: null,
previous: null,
results: []
};
if (response.data.online) {
translatedResponse.count = 1;
translatedResponse.results.push({
"friendly_token": "*********",
"url": window.MediaCMS.site.livestream.uri,
"api_url": window.MediaCMS.site.livestream.uri,
"user": "live",
"title": (response.data.streamTitle || "Live now"),
"description": "Watch live now",
"add_date": response.data.lastConnectTime,
"views": response.data.viewerCount,
"media_type": "video",
"state": "public",
"duration": Math.floor(((new Date()).getTime() - new Date(Date.parse(response.data.lastConnectTime)).getTime()) / 1000),
"thumbnail_url": (window.MediaCMS.site.livestream.uri + "/thumbnail.jpg"),
"is_reviewed": true,
"author_name": "Live stream",
"author_profile": window.MediaCMS.site.livestream.uri,
"author_thumbnail": "/media/userlogos/user.jpg",
"encoding_status": "success",
"likes": 1,
"dislikes": 0,
"featured": true,
"user_featured": false,
"size": "999MB"
});
}
return {
"data": translatedResponse,
"status": response.status,
"statusText": response.statusText
};
};

const onLoadLive = (length: number) => {
setVisibleLive(0 < length);
};

const onLoadFeatured = (length: number) => {
setVisibleFeatured(0 < length);
Expand All @@ -67,6 +145,23 @@ export const HomePage: React.FC<HomePageProps> = ({
<ApiUrlConsumer>
{(apiUrl) => (
<MediaMultiListWrapper className="items-list-ver">
{window.MediaCMS.site.livestream && window.MediaCMS.site.livestream.backend == "owncast" &&
window.MediaCMS.site.livestream.uri && (
<MediaListRow
title="Live"
style={!visibleLive ? { display: 'none' } : undefined}
>
<InlineSliderItemListAsync
requestUrl={window.MediaCMS.site.livestream.uri + "/api/status"}
translateCallback={translateLive}
itemsCountCallback={onLoadLive}
hideViews={!PageStore.get('config-media-item').displayViews}
hideAuthor={!PageStore.get('config-media-item').displayAuthor}
hideDate={!PageStore.get('config-media-item').displayPublishDate}
/>
</MediaListRow>
)}

{PageStore.get('config-enabled').pages.featured &&
PageStore.get('config-enabled').pages.featured.enabled && (
<MediaListRow
Expand Down
8 changes: 8 additions & 0 deletions templates/config/installation/site.html
Original file line number Diff line number Diff line change
Expand Up @@ -49,5 +49,13 @@
title: 'Categories',
},
},
{% if LIVESTREAM_BACKEND %}
livestream: {
backend: '{{LIVESTREAM_BACKEND}}',
{% if LIVESTREAM_URI %}
uri: '{{LIVESTREAM_URI}}',
{% endif %}
},
{% endif %}
};