Skip to content
This repository has been archived by the owner on Nov 5, 2023. It is now read-only.

Commit

Permalink
Add experimental YouTube caption integration that doesn't really work
Browse files Browse the repository at this point in the history
  • Loading branch information
curtgrimes committed Sep 3, 2020
1 parent ed3e85d commit b2bbc2a
Show file tree
Hide file tree
Showing 13 changed files with 419 additions and 128 deletions.
25 changes: 23 additions & 2 deletions app/api/routes/channels/index.js
@@ -1,6 +1,7 @@
const channels = require('express').Router();

channels.use('/zoom', require('./zoom'));
channels.use('/youtube', require('./youtube'));

const iconPrefix = '/static/channel-icons';
const configPatePathPrefix = '/captioner/settings/channels/new?type=';
Expand Down Expand Up @@ -55,12 +56,32 @@ const channelsList = [
iconPath: `${iconPrefix}/youtube.png`,
limit: 1,
configPagePath: `${configPatePathPrefix}youtube`,
requiredExperiment: 'youtube',
},
];

channels.get('/', async (req, res, next) => {
// Sort alphabetically by name
res.send(channelsList.sort((a, b) => a.name.localeCompare(b.name)));
let { experiments } = req.query;
experiments = experiments ? String(experiments).split(',') : [];

let channelsToReturn = [
...channelsList.sort((a, b) =>
// Sort alphabetically by name
a.name.localeCompare(b.name)
),
].map((c) => ({ ...c })); // clone

// Filter out any channels that require experiments
// to be enabled, unless those experiments are enabled
channelsToReturn = channelsToReturn.filter(
(channel) =>
!channel.requiredExperiment ||
experiments.includes(channel.requiredExperiment)
);

channelsToReturn.forEach((channel) => delete channel.requiredExperiment);

res.send(channelsToReturn);
});

channels.get('/:id', async (req, res, next) => {
Expand Down
76 changes: 76 additions & 0 deletions app/api/routes/channels/youtube/index.js
@@ -0,0 +1,76 @@
const youtube = require('express').Router();
const axios = require('axios');
const rateLimit = require('express-rate-limit');

const rateLimitWindowMinutes = 5;
const requestsAllowedPerSecond = 1; // Frontend limits to one request per second
const rateLimitLeeway = 10;
const rateLimiter = rateLimit({
windowMs: rateLimitWindowMinutes * 60 * 1000,
max: rateLimitWindowMinutes * requestsAllowedPerSecond * 60 + rateLimitLeeway,
});

youtube.use('/', rateLimiter);

youtube.post('/', async (req, res) => {
const { apiPath, transcript } = req.body;
if (!apiPath || !transcript) {
return res.sendStatus(400);
}

try {
// Verify this is actually a URL and a YouTube closed caption URL
const url = new URL(apiPath);
if (
url.origin !== 'http://upload.youtube.com' ||
url.pathname !== '/closedcaption'
) {
throw new Error();
}
} catch (e) {
return res.sendStatus(400);
}

// At one point I thought doing this might improve caption delay
// in YouTube but I don't think so
const offsetTimestampSeconds = 0;

const body = `${
new Date(new Date().getTime() - 1000 * offsetTimestampSeconds)
.toISOString()
.split('Z')[0]
}\n${transcript}\n`;

axios
.post(apiPath, body, {
headers: {
'Content-Type': 'text/plain',
},
})
.then(() => {
// We got a successful response
res.sendStatus(200);
})
.catch((e) => {
if (e.code === 'ENOTFOUND') {
return res.sendStatus(404);
} else {
const errorCode =
e && e.response && e.response.status ? e.response.status : undefined;
switch (errorCode) {
case 400:
return res
.status(400)
.send(
`Error: The Zoom meeting has not started yet or it has already ended.`
);
default:
return res
.status(520)
.send(`Something went wrong. (${errorCode || 'Unknown error'})`);
}
}
});
});

module.exports = youtube;
2 changes: 1 addition & 1 deletion app/api/routes/channels/zoom/index.js
Expand Up @@ -12,7 +12,7 @@ const zoomRateLimiter = rateLimit({

zoom.use('/', zoomRateLimiter);

zoom.post('/api', async (req, res) => {
zoom.post('/', async (req, res) => {
const { apiPath, transcript } = req.body;
if (!apiPath || !transcript) {
return res.sendStatus(400);
Expand Down
4 changes: 2 additions & 2 deletions app/components/channels/ChannelsPopup.vue
Expand Up @@ -72,8 +72,8 @@ export default {
channels: [],
};
},
async mounted() {
this.channels = await this.$axios.$get('/api/channels');
async created() {
this.channels = await this.$store.dispatch('channels/GET_CHANNELS');
},
methods: {
toggleChannel(channelId, onOrOff) {
Expand Down
87 changes: 87 additions & 0 deletions app/components/channels/editors/youtube.vue
@@ -0,0 +1,87 @@
<template>
<div>
<img
:src="channel.iconPath"
class="w-100 col-6 d-block mx-auto mt-2 mb-3"
alt="YouTube"
/>
<p class="lead text-center">
Send real-time captions to a YouTube live stream.
</p>
<hr />
<ol>
<li>
In YouTube Studio, set up a live stream. Go to stream settings and
enable closed captions.
</li>
<li>
Select "Post captions to URL."
</li>
<li>
Copy the captions ingestion URL and paste it here.
</li>
</ol>
<div class="card card-body">
<div
v-if="savedChannel && savedChannel.error"
class="alert alert-warning small"
>
<strong class="text-danger">
<fa icon="exclamation-triangle" /> Error:
</strong>
{{ savedChannel.error }}
</div>
<label for="url" class="small">
YouTube captions ingestion URL
</label>
<input
id="url"
name="url"
v-model="url"
autofocus
class="form-control"
type="url"
placeholder="YouTube captions ingestion URL"
/>
</div>
</div>
</template>

<script>
export default {
props: {
channel: {
required: true,
type: Object,
},
savedChannel: {
required: false,
type: Object,
},
},
mounted() {
this.url = this.savedChannel?.parameters?.url;
},
data() {
return {
url: null,
};
},
watch: {
url: {
immediate: true,
handler(url) {
this.$emit('parametersUpdated', {
url,
});
if (this.url) {
this.$emit('formValid');
} else {
this.$emit('formInvalid');
}
},
},
},
};
</script>
22 changes: 17 additions & 5 deletions app/pages/captioner/settings/channels.vue
Expand Up @@ -141,11 +141,23 @@ export default {
channels: [],
};
},
async asyncData({ $axios }) {
const channels = await $axios.$get('/api/channels');
return {
channels,
};
async created() {
// Wait until settings are loaded
if (!this.$store.state.settingsLoaded) {
await new Promise((resolve) => {
this.$store.watch(
(state) => {
return state.settingsLoaded;
},
(loaded) => {
if (loaded) {
resolve();
}
}
);
});
}
this.channels = await this.$store.dispatch('channels/GET_CHANNELS');
},
methods: {
channelInfo(id) {
Expand Down
2 changes: 1 addition & 1 deletion app/pages/captioner/settings/channels/_channelId.vue
Expand Up @@ -67,7 +67,7 @@ export default {
},
async beforeCreate() {
try {
const channels = await this.$axios.$get('/api/channels');
const channels = await this.$store.dispatch('channels/GET_CHANNELS');
await this.settingsLoaded();
Expand Down
114 changes: 0 additions & 114 deletions app/pages/captioner/settings/channels/deletemezoom.vue

This file was deleted.

6 changes: 6 additions & 0 deletions app/pages/captioner/settings/experiments/index.vue
Expand Up @@ -222,6 +222,12 @@ export default {
description:
'After speech is converted to text, convert the text back to speech using speech synthesis.',
},
{
id: 'youtube',
name: 'YouTube integration',
description:
'Add an experimental YouTube live closed captions integration. Go to the Channels page to set it up.',
},
],
experimentIdToAdd: '',
Expand Down

0 comments on commit b2bbc2a

Please sign in to comment.