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

Commit b2bbc2a

Browse files
committed
Add experimental YouTube caption integration that doesn't really work
1 parent ed3e85d commit b2bbc2a

File tree

13 files changed

+419
-128
lines changed

13 files changed

+419
-128
lines changed

app/api/routes/channels/index.js

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
const channels = require('express').Router();
22

33
channels.use('/zoom', require('./zoom'));
4+
channels.use('/youtube', require('./youtube'));
45

56
const iconPrefix = '/static/channel-icons';
67
const configPatePathPrefix = '/captioner/settings/channels/new?type=';
@@ -55,12 +56,32 @@ const channelsList = [
5556
iconPath: `${iconPrefix}/youtube.png`,
5657
limit: 1,
5758
configPagePath: `${configPatePathPrefix}youtube`,
59+
requiredExperiment: 'youtube',
5860
},
5961
];
6062

6163
channels.get('/', async (req, res, next) => {
62-
// Sort alphabetically by name
63-
res.send(channelsList.sort((a, b) => a.name.localeCompare(b.name)));
64+
let { experiments } = req.query;
65+
experiments = experiments ? String(experiments).split(',') : [];
66+
67+
let channelsToReturn = [
68+
...channelsList.sort((a, b) =>
69+
// Sort alphabetically by name
70+
a.name.localeCompare(b.name)
71+
),
72+
].map((c) => ({ ...c })); // clone
73+
74+
// Filter out any channels that require experiments
75+
// to be enabled, unless those experiments are enabled
76+
channelsToReturn = channelsToReturn.filter(
77+
(channel) =>
78+
!channel.requiredExperiment ||
79+
experiments.includes(channel.requiredExperiment)
80+
);
81+
82+
channelsToReturn.forEach((channel) => delete channel.requiredExperiment);
83+
84+
res.send(channelsToReturn);
6485
});
6586

6687
channels.get('/:id', async (req, res, next) => {
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
const youtube = require('express').Router();
2+
const axios = require('axios');
3+
const rateLimit = require('express-rate-limit');
4+
5+
const rateLimitWindowMinutes = 5;
6+
const requestsAllowedPerSecond = 1; // Frontend limits to one request per second
7+
const rateLimitLeeway = 10;
8+
const rateLimiter = rateLimit({
9+
windowMs: rateLimitWindowMinutes * 60 * 1000,
10+
max: rateLimitWindowMinutes * requestsAllowedPerSecond * 60 + rateLimitLeeway,
11+
});
12+
13+
youtube.use('/', rateLimiter);
14+
15+
youtube.post('/', async (req, res) => {
16+
const { apiPath, transcript } = req.body;
17+
if (!apiPath || !transcript) {
18+
return res.sendStatus(400);
19+
}
20+
21+
try {
22+
// Verify this is actually a URL and a YouTube closed caption URL
23+
const url = new URL(apiPath);
24+
if (
25+
url.origin !== 'http://upload.youtube.com' ||
26+
url.pathname !== '/closedcaption'
27+
) {
28+
throw new Error();
29+
}
30+
} catch (e) {
31+
return res.sendStatus(400);
32+
}
33+
34+
// At one point I thought doing this might improve caption delay
35+
// in YouTube but I don't think so
36+
const offsetTimestampSeconds = 0;
37+
38+
const body = `${
39+
new Date(new Date().getTime() - 1000 * offsetTimestampSeconds)
40+
.toISOString()
41+
.split('Z')[0]
42+
}\n${transcript}\n`;
43+
44+
axios
45+
.post(apiPath, body, {
46+
headers: {
47+
'Content-Type': 'text/plain',
48+
},
49+
})
50+
.then(() => {
51+
// We got a successful response
52+
res.sendStatus(200);
53+
})
54+
.catch((e) => {
55+
if (e.code === 'ENOTFOUND') {
56+
return res.sendStatus(404);
57+
} else {
58+
const errorCode =
59+
e && e.response && e.response.status ? e.response.status : undefined;
60+
switch (errorCode) {
61+
case 400:
62+
return res
63+
.status(400)
64+
.send(
65+
`Error: The Zoom meeting has not started yet or it has already ended.`
66+
);
67+
default:
68+
return res
69+
.status(520)
70+
.send(`Something went wrong. (${errorCode || 'Unknown error'})`);
71+
}
72+
}
73+
});
74+
});
75+
76+
module.exports = youtube;

app/api/routes/channels/zoom/index.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ const zoomRateLimiter = rateLimit({
1212

1313
zoom.use('/', zoomRateLimiter);
1414

15-
zoom.post('/api', async (req, res) => {
15+
zoom.post('/', async (req, res) => {
1616
const { apiPath, transcript } = req.body;
1717
if (!apiPath || !transcript) {
1818
return res.sendStatus(400);

app/components/channels/ChannelsPopup.vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,8 @@ export default {
7272
channels: [],
7373
};
7474
},
75-
async mounted() {
76-
this.channels = await this.$axios.$get('/api/channels');
75+
async created() {
76+
this.channels = await this.$store.dispatch('channels/GET_CHANNELS');
7777
},
7878
methods: {
7979
toggleChannel(channelId, onOrOff) {
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
<template>
2+
<div>
3+
<img
4+
:src="channel.iconPath"
5+
class="w-100 col-6 d-block mx-auto mt-2 mb-3"
6+
alt="YouTube"
7+
/>
8+
<p class="lead text-center">
9+
Send real-time captions to a YouTube live stream.
10+
</p>
11+
<hr />
12+
<ol>
13+
<li>
14+
In YouTube Studio, set up a live stream. Go to stream settings and
15+
enable closed captions.
16+
</li>
17+
<li>
18+
Select "Post captions to URL."
19+
</li>
20+
<li>
21+
Copy the captions ingestion URL and paste it here.
22+
</li>
23+
</ol>
24+
<div class="card card-body">
25+
<div
26+
v-if="savedChannel && savedChannel.error"
27+
class="alert alert-warning small"
28+
>
29+
<strong class="text-danger">
30+
<fa icon="exclamation-triangle" /> Error:
31+
</strong>
32+
{{ savedChannel.error }}
33+
</div>
34+
<label for="url" class="small">
35+
YouTube captions ingestion URL
36+
</label>
37+
<input
38+
id="url"
39+
name="url"
40+
v-model="url"
41+
autofocus
42+
class="form-control"
43+
type="url"
44+
placeholder="YouTube captions ingestion URL"
45+
/>
46+
</div>
47+
</div>
48+
</template>
49+
50+
<script>
51+
export default {
52+
props: {
53+
channel: {
54+
required: true,
55+
type: Object,
56+
},
57+
savedChannel: {
58+
required: false,
59+
type: Object,
60+
},
61+
},
62+
mounted() {
63+
this.url = this.savedChannel?.parameters?.url;
64+
},
65+
data() {
66+
return {
67+
url: null,
68+
};
69+
},
70+
watch: {
71+
url: {
72+
immediate: true,
73+
handler(url) {
74+
this.$emit('parametersUpdated', {
75+
url,
76+
});
77+
78+
if (this.url) {
79+
this.$emit('formValid');
80+
} else {
81+
this.$emit('formInvalid');
82+
}
83+
},
84+
},
85+
},
86+
};
87+
</script>

app/pages/captioner/settings/channels.vue

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -141,11 +141,23 @@ export default {
141141
channels: [],
142142
};
143143
},
144-
async asyncData({ $axios }) {
145-
const channels = await $axios.$get('/api/channels');
146-
return {
147-
channels,
148-
};
144+
async created() {
145+
// Wait until settings are loaded
146+
if (!this.$store.state.settingsLoaded) {
147+
await new Promise((resolve) => {
148+
this.$store.watch(
149+
(state) => {
150+
return state.settingsLoaded;
151+
},
152+
(loaded) => {
153+
if (loaded) {
154+
resolve();
155+
}
156+
}
157+
);
158+
});
159+
}
160+
this.channels = await this.$store.dispatch('channels/GET_CHANNELS');
149161
},
150162
methods: {
151163
channelInfo(id) {

app/pages/captioner/settings/channels/_channelId.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ export default {
6767
},
6868
async beforeCreate() {
6969
try {
70-
const channels = await this.$axios.$get('/api/channels');
70+
const channels = await this.$store.dispatch('channels/GET_CHANNELS');
7171
7272
await this.settingsLoaded();
7373

app/pages/captioner/settings/channels/deletemezoom.vue

Lines changed: 0 additions & 114 deletions
This file was deleted.

app/pages/captioner/settings/experiments/index.vue

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,12 @@ export default {
222222
description:
223223
'After speech is converted to text, convert the text back to speech using speech synthesis.',
224224
},
225+
{
226+
id: 'youtube',
227+
name: 'YouTube integration',
228+
description:
229+
'Add an experimental YouTube live closed captions integration. Go to the Channels page to set it up.',
230+
},
225231
],
226232
227233
experimentIdToAdd: '',

0 commit comments

Comments
 (0)