feat(content): subtitle/caption support as a content property (#223)

Subtitles/captions are set once in the content library and applied
automatically by the player — no in-player controls (the player stays bare).

- DB: 4 new content columns (captions_enabled, captions_lang for YouTube;
  subtitle_url, subtitle_lang for uploaded videos), all default off/NULL.
- buildSnapshotItems: denormalize the 4 fields into published_snapshot so the
  player receives them (enumerated query).
- content.js PUT: accept the 4 fields (subtitle_url only clearable here).
- POST /:id/subtitle: dedicated .vtt uploader (separate multer, since the main
  filter is video/image-only); stores the file in the content dir, records
  subtitle_url + subtitle_lang. Old subtitle file replaced; DELETE cleans up
  the sidecar.
- Player: YouTube -> loadModule('captions') + setOption(...languageCode) in
  onReady (best-effort, wrapped). Uploaded video -> a <track kind="subtitles">
  appended to the <video>, forced mode='showing' on load (same-origin, so
  CORS-clean like the video).
- Edit modal: YouTube gets an enable-captions checkbox + language; uploaded
  video gets a .vtt file picker + language + a remove-subtitle option. en/es.

Honest limitation (in the PR): YouTube caption control via the IFrame API is
undocumented/version-dependent and only works if the video actually has
captions — hence best-effort and wrapped so it can never break playback.

Test: content-subtitles.test.js — the 4 fields survive publish -> snapshot,
the .vtt upload endpoint stores + records the file, and a non-.vtt is rejected.
Suite 550/550.

Closes #216

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
screentinker 2026-07-23 12:33:35 -05:00 committed by GitHub
parent 8b661a7347
commit 8529be5a30
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 288 additions and 1 deletions

View file

@ -239,6 +239,14 @@ export default {
'content.expires_hint': 'After this date the item stops serving and is dropped from playlists. Leave blank for no expiry.',
'content.label_unstable_connection': 'Unstable connection (cap at 720p)',
'content.unstable_connection_hint': 'Forces YouTube to a 720p ceiling so weak or unstable WiFi buffers less. Leave off for full quality.',
'content.label_captions_enabled': 'Enable captions',
'content.label_captions_lang': 'Caption language',
'content.captions_hint': 'Turns on YouTube captions in the chosen language on every display. Availability depends on the video having captions.',
'content.label_subtitle_file': 'Subtitle file (.vtt)',
'content.label_subtitle_lang': 'Subtitle language',
'content.subtitle_current': 'A subtitle track is attached. Choose a new file to replace it.',
'content.subtitle_remove': 'Remove the current subtitle',
'content.subtitle_hint': 'Upload a WebVTT (.vtt) file to show subtitles on this video across all displays.',
'content.label_replace_file': 'Replace File',
'content.replace_file_hint': 'Leave empty to keep current file',
'content.folder_root_option': '— Root —',

View file

@ -196,6 +196,14 @@ export default {
'content.label_folder': 'Carpeta',
'content.label_unstable_connection': 'Conexión inestable (limitar a 720p)',
'content.unstable_connection_hint': 'Fuerza un tope de 720p en YouTube para que el WiFi débil o inestable almacene menos en búfer. Déjalo desactivado para calidad completa.',
'content.label_captions_enabled': 'Activar subtítulos',
'content.label_captions_lang': 'Idioma de subtítulos',
'content.captions_hint': 'Activa los subtítulos de YouTube en el idioma elegido en todas las pantallas. Depende de que el video tenga subtítulos.',
'content.label_subtitle_file': 'Archivo de subtítulos (.vtt)',
'content.label_subtitle_lang': 'Idioma del subtítulo',
'content.subtitle_current': 'Hay una pista de subtítulos adjunta. Elige un archivo nuevo para reemplazarla.',
'content.subtitle_remove': 'Quitar el subtítulo actual',
'content.subtitle_hint': 'Sube un archivo WebVTT (.vtt) para mostrar subtítulos en este video en todas las pantallas.',
'content.label_replace_file': 'Reemplazar archivo',
'content.replace_file_hint': 'Déjalo vacío para mantener el archivo actual',
'content.folder_root_option': '— Raíz —',

View file

@ -3,6 +3,14 @@ import { showToast } from '../components/toast.js';
import { esc, hydrateAuthImages } from '../utils.js';
import { t } from '../i18n.js';
// #216: languages offered in the caption/subtitle pickers. Codes are BCP-47 primary tags —
// enough for signage; extend as needed.
const SUBTITLE_LANGS = [
['en', 'English'], ['es', 'Español'], ['fr', 'Français'], ['de', 'Deutsch'],
['pt', 'Português'], ['it', 'Italiano'], ['nl', 'Nederlands'], ['ja', '日本語'],
['ko', '한국어'], ['zh', '中文'],
];
function formatFileSize(bytes) {
if (!bytes) return '--';
if (bytes >= 1073741824) return `${(bytes / 1073741824).toFixed(1)} GB`;
@ -672,6 +680,11 @@ function showEditModal(contentItem, onSave) {
const isRemote = !!contentItem.remote_url;
const isYoutube = contentItem.mime_type === 'video/youtube';
const isUploadedVideo = !isRemote && contentItem.mime_type?.startsWith('video/');
// #216: language <option>s shared by the caption + subtitle pickers.
const langOptions = (sel) => SUBTITLE_LANGS
.map(([code, label]) => `<option value="${code}" ${sel === code ? 'selected' : ''}>${label}</option>`)
.join('');
overlay.innerHTML = `
<div class="modal" style="max-width:500px;width:95vw">
@ -724,6 +737,32 @@ function showEditModal(contentItem, onSave) {
<p style="font-size:11px;color:var(--text-muted);margin-top:4px">${t('content.unstable_connection_hint')}</p>
</div>
` : ''}
${isYoutube ? `
<div class="form-group">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="editCaptionsEnabled" ${contentItem.captions_enabled ? 'checked' : ''} style="width:auto;margin:0">
<span>${t('content.label_captions_enabled')}</span>
</label>
<div style="margin-top:8px">
<label style="font-size:12px;color:var(--text-secondary)">${t('content.label_captions_lang')}</label>
<select id="editCaptionsLang" class="input" style="background:var(--bg-input)">${langOptions(contentItem.captions_lang || 'en')}</select>
</div>
<p style="font-size:11px;color:var(--text-muted);margin-top:4px">${t('content.captions_hint')}</p>
</div>
` : ''}
${isUploadedVideo ? `
<div class="form-group">
<label>${t('content.label_subtitle_file')}</label>
${contentItem.subtitle_url ? `<p style="font-size:11px;color:var(--text-secondary);margin:2px 0 6px">${t('content.subtitle_current')}</p>` : ''}
<input type="file" id="editSubtitleFile" accept=".vtt,text/vtt" style="font-size:13px;color:var(--text-secondary)">
<div style="margin-top:8px">
<label style="font-size:12px;color:var(--text-secondary)">${t('content.label_subtitle_lang')}</label>
<select id="editSubtitleLang" class="input" style="background:var(--bg-input)">${langOptions(contentItem.subtitle_lang || 'en')}</select>
</div>
${contentItem.subtitle_url ? `<label style="display:flex;align-items:center;gap:8px;cursor:pointer;margin-top:8px"><input type="checkbox" id="editSubtitleRemove" style="width:auto;margin:0"><span>${t('content.subtitle_remove')}</span></label>` : ''}
<p style="font-size:11px;color:var(--text-muted);margin-top:4px">${t('content.subtitle_hint')}</p>
</div>
` : ''}
${!isRemote ? `
<div class="form-group">
<label>${t('content.label_replace_file')}</label>
@ -772,6 +811,26 @@ function showEditModal(contentItem, onSave) {
const newUnstable = unstableEl.checked ? 1 : 0;
if (newUnstable !== (contentItem.unstable_connection ? 1 : 0)) updateData.unstable_connection = newUnstable;
}
// #216: YouTube captions (checkbox + language).
const captionsEl = overlay.querySelector('#editCaptionsEnabled');
if (captionsEl) {
const newCaptions = captionsEl.checked ? 1 : 0;
if (newCaptions !== (contentItem.captions_enabled ? 1 : 0)) updateData.captions_enabled = newCaptions;
const capLang = overlay.querySelector('#editCaptionsLang')?.value || null;
if (capLang !== (contentItem.captions_lang || 'en')) updateData.captions_lang = capLang;
}
// #216: uploaded-video subtitle language change / removal (the FILE is sent separately below).
const subtitleFile = overlay.querySelector('#editSubtitleFile')?.files[0];
const subLangEl = overlay.querySelector('#editSubtitleLang');
const subRemove = overlay.querySelector('#editSubtitleRemove')?.checked;
if (subRemove) {
updateData.subtitle_url = null;
updateData.subtitle_lang = null;
} else if (subLangEl && !subtitleFile) {
// Lang-only change (no new file) — the upload endpoint handles lang when a file IS sent.
const subLang = subLangEl.value || null;
if (contentItem.subtitle_url && subLang !== (contentItem.subtitle_lang || 'en')) updateData.subtitle_lang = subLang;
}
if (Object.keys(updateData).length > 0) {
await fetch('/api/content/' + contentItem.id, {
@ -792,6 +851,18 @@ function showEditModal(contentItem, onSave) {
});
}
// #216: upload a new subtitle .vtt if one was chosen (skipped when "remove" is ticked).
if (subtitleFile && !subRemove) {
const subForm = new FormData();
subForm.append('subtitle', subtitleFile);
if (subLangEl?.value) subForm.append('subtitle_lang', subLangEl.value);
await fetch('/api/content/' + contentItem.id + '/subtitle', {
method: 'POST',
headers,
body: subForm
});
}
overlay.remove();
showToast(t('content.toast.updated'), 'success');
if (onSave) onSave();

View file

@ -380,6 +380,15 @@ const migrations = [
// embed at 720p (playerVars.vq='hd720') so weak/unstable WiFi on Android TV doesn't
// buffer/stall on an auto-selected 1080p+ stream. DEFAULT 0 = no cap (today's behaviour).
"ALTER TABLE content ADD COLUMN unstable_connection INTEGER NOT NULL DEFAULT 0",
// #216: subtitle/caption support as a content property (applied automatically by the
// player, no in-player controls). YouTube uses captions_enabled + captions_lang (via the
// IFrame API); uploaded videos use subtitle_url (a .vtt filename in the content dir,
// served at /uploads/content/<file>) + subtitle_lang for the <track> element. All default
// off/NULL so existing content is unchanged.
"ALTER TABLE content ADD COLUMN captions_enabled INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE content ADD COLUMN captions_lang TEXT",
"ALTER TABLE content ADD COLUMN subtitle_url TEXT",
"ALTER TABLE content ADD COLUMN subtitle_lang TEXT",
];
// Apply each ALTER idempotently. A "duplicate column name" / "already exists"
// error means the column is already present (expected on a migrated DB) - benign.

View file

@ -54,4 +54,25 @@ const upload = multer({
defParamCharset: 'utf8'
});
// #216: dedicated uploader for WebVTT subtitle files. The main `fileFilter` only allows
// video/image, so subtitles need their own instance. Written into the same content dir
// (served at /uploads/content/<file>) with a .vtt name; capped small — subtitles are tiny.
const subtitleStorage = multer.diskStorage({
destination: (req, file, cb) => cb(null, config.contentDir),
filename: (req, file, cb) => cb(null, `${uuidv4()}.vtt`),
});
const subtitleUpload = multer({
storage: subtitleStorage,
limits: { fileSize: 2 * 1024 * 1024 }, // 2MB — generous for a subtitle track
fileFilter: (req, file, cb) => {
// Browsers send .vtt as text/vtt; some send text/plain or application/octet-stream.
// Gate on the extension (authoritative here) plus those benign text mimetypes.
const okExt = /\.vtt$/i.test(file.originalname || '');
const okMime = ['text/vtt', 'text/plain', 'application/octet-stream'].includes(file.mimetype);
if (okExt && okMime) return cb(null, true);
cb(new Error('Only .vtt subtitle files are allowed'), false);
},
});
upload.subtitleUpload = subtitleUpload;
module.exports = upload;

View file

@ -2071,6 +2071,14 @@
if (item.unstable_connection) {
try { event.target.setPlaybackQuality('hd720'); } catch {}
}
// #216: turn on YouTube captions when the content is flagged. loadModule
// makes the CC module available, then setOption selects the language track.
// Both are best-effort (undocumented, version-dependent) — wrapped so a
// throw can never break playback.
if (item.captions_enabled) {
try { event.target.loadModule('captions'); } catch {}
try { event.target.setOption('captions', 'track', { languageCode: item.captions_lang || 'en' }); } catch {}
}
},
onError: (event) => {
if (myGeneration !== ytGeneration) return;
@ -2462,6 +2470,22 @@
video.crossOrigin = 'anonymous'; // texturable (CORS-clean) + matches the legacy branch
video.playsInline = true;
video.preload = 'auto';
// #216: attach a WebVTT subtitle track when the content carries one (uploaded videos
// only; remote/proxied clips are skipped). Served same-origin from /uploads/content,
// so it's CORS-clean like the video. `default` + mode='showing' makes it visible with
// no player controls. Old players ignore an absent subtitle_url.
if (item.subtitle_url && !item.remote_url) {
const track = document.createElement('track');
track.kind = 'subtitles';
track.srclang = item.subtitle_lang || 'en';
track.label = item.subtitle_lang || 'Subtitles';
track.default = true;
track.src = `${config.serverUrl}/uploads/content/${item.subtitle_url}`;
video.appendChild(track);
// The `default` attribute alone doesn't always engage without controls; force it on
// once the track has loaded its cues.
track.addEventListener('load', () => { try { track.track.mode = 'showing'; } catch (e) {} });
}
video.muted = true; // warm-play MUST be muted (autoplay policy); real mute set at mount
video.loop = (playlist.length === 1); // single-item playlist holds by looping
video.style.cssText = 'width:100%;height:100%;object-fit:contain;background:#000';

View file

@ -409,7 +409,8 @@ router.put('/:id', (req, res) => {
const content = checkContentWrite(req, res);
if (!content) return;
const { filename, mime_type, remote_url, folder, folder_id, expires_at, unstable_connection } = req.body;
const { filename, mime_type, remote_url, folder, folder_id, expires_at, unstable_connection,
captions_enabled, captions_lang, subtitle_url, subtitle_lang } = req.body;
const updates = [];
const values = [];
if (filename !== undefined) { updates.push('filename = ?'); values.push(safeFilename(filename)); }
@ -462,6 +463,21 @@ router.put('/:id', (req, res) => {
updates.push('unstable_connection = ?');
values.push(unstable_connection ? 1 : 0);
}
// #216: caption/subtitle metadata. The subtitle FILE is uploaded via POST /:id/subtitle;
// these fields toggle YouTube captions, set languages, or clear a subtitle (subtitle_url=null).
if (captions_enabled !== undefined) {
updates.push('captions_enabled = ?'); values.push(captions_enabled ? 1 : 0);
}
if (captions_lang !== undefined) {
updates.push('captions_lang = ?'); values.push(captions_lang ? String(captions_lang).slice(0, 10) : null);
}
if (subtitle_url !== undefined) {
// Only null (clear) is accepted here — a real subtitle_url is set by the upload endpoint.
updates.push('subtitle_url = ?'); values.push(subtitle_url ? String(subtitle_url).slice(0, 255) : null);
}
if (subtitle_lang !== undefined) {
updates.push('subtitle_lang = ?'); values.push(subtitle_lang ? String(subtitle_lang).slice(0, 10) : null);
}
if (updates.length > 0) {
values.push(req.params.id);
@ -512,6 +528,29 @@ router.put('/:id/replace', upload.single('file'), async (req, res) => {
res.json(db.prepare('SELECT * FROM content WHERE id = ?').get(req.params.id));
});
// #216: upload a WebVTT subtitle track for an uploaded video. Stores the .vtt in the
// content dir (served at /uploads/content/<file>) and records its filename + language on
// the content row. Replaces any existing subtitle (old file removed).
router.post('/:id/subtitle', upload.subtitleUpload.single('subtitle'), async (req, res) => {
const content = checkContentWrite(req, res);
if (!content) {
// checkContentWrite already sent the response; clean up the orphaned upload.
if (req.file) { try { fs.unlinkSync(req.file.path); } catch {} }
return;
}
if (!req.file) return res.status(400).json({ error: 'No subtitle file provided' });
// Remove the previous subtitle file if there was one.
if (content.subtitle_url) {
const old = path.join(config.contentDir, path.basename(content.subtitle_url));
if (fs.existsSync(old)) { try { fs.unlinkSync(old); } catch {} }
}
const lang = req.body.subtitle_lang ? String(req.body.subtitle_lang).slice(0, 10) : (content.subtitle_lang || null);
db.prepare('UPDATE content SET subtitle_url = ?, subtitle_lang = ? WHERE id = ?')
.run(req.file.filename, lang, req.params.id);
res.json(db.prepare('SELECT * FROM content WHERE id = ?').get(req.params.id));
});
// Serve content file
router.get('/:id/file', (req, res) => {
const content = checkContentRead(req, res);

View file

@ -69,6 +69,7 @@ function buildSnapshotItems(playlistId) {
SELECT pi.id AS _iid, pi.content_id, pi.widget_id, pi.zone_id, pi.sort_order, pi.duration_sec, pi.muted,
COALESCE(c.filename, w.name) as filename, c.mime_type, c.filepath, c.file_size,
c.duration_sec as content_duration, c.remote_url, c.unstable_connection,
c.captions_enabled, c.captions_lang, c.subtitle_url, c.subtitle_lang,
w.name as widget_name, w.widget_type, w.config as widget_config
FROM playlist_items pi
LEFT JOIN content c ON pi.content_id = c.id

View file

@ -0,0 +1,106 @@
'use strict';
// #216 subtitle/caption support. Two risky paths:
// 1. the 4 new content fields reach the player via buildSnapshotItems (enumerated query).
// 2. POST /:id/subtitle stores the .vtt and records subtitle_url + subtitle_lang.
const os = require('node:os');
const path = require('node:path');
const fs = require('node:fs');
const crypto = require('node:crypto');
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'st-subs-'));
process.env.DATA_DIR = TMP;
process.env.SELF_HOSTED = 'true';
process.env.NODE_ENV = 'test';
const { test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const http = require('node:http');
const express = require('express');
const { db } = require('../db/database');
const { publishPlaylist } = require('../routes/playlists');
const uid = (p) => p + '-' + crypto.randomBytes(4).toString('hex');
const USER = 'u-subs';
let server, base;
before(async () => {
fs.mkdirSync(require('../config').contentDir, { recursive: true });
db.prepare("INSERT INTO users (id, email, password_hash, plan_id) VALUES (?, ?, 'x', 'free')").run(USER, USER + '@t.local');
// Platform-admin stub + platform-template content (workspace_id NULL) so checkContentWrite
// grants access without standing up full workspace membership — this test is about the
// subtitle endpoint mechanics, not tenancy (covered elsewhere).
const app = express();
app.use((req, _res, next) => { req.workspaceId = 'ws-subs'; req.user = { id: USER, role: 'platform_admin' }; next(); });
app.use('/', require('../routes/content'));
server = http.createServer(app);
await new Promise((r) => server.listen(0, r));
base = `http://127.0.0.1:${server.address().port}`;
});
after(() => new Promise((r) => server.close(r)));
test('published snapshot carries caption + subtitle fields to the player', () => {
const c = uid('cap');
db.prepare(`INSERT INTO content (id, filename, mime_type, captions_enabled, captions_lang, subtitle_url, subtitle_lang)
VALUES (?, ?, 'video/youtube', 1, 'es', 'sub.vtt', 'fr')`).run(c, c);
const pl = uid('pl');
db.prepare("INSERT INTO playlists (id, user_id, name, status) VALUES (?, ?, 'P', 'draft')").run(pl, USER);
db.prepare('INSERT INTO playlist_items (playlist_id, content_id, sort_order) VALUES (?, ?, 0)').run(pl, c);
publishPlaylist(pl);
const item = JSON.parse(db.prepare('SELECT published_snapshot FROM playlists WHERE id = ?').get(pl).published_snapshot)[0];
assert.equal(item.captions_enabled, 1);
assert.equal(item.captions_lang, 'es');
assert.equal(item.subtitle_url, 'sub.vtt');
assert.equal(item.subtitle_lang, 'fr');
});
test('POST /:id/subtitle stores the .vtt and records url + lang', async () => {
const c = uid('vid');
db.prepare("INSERT INTO content (id, filename, mime_type, filepath) VALUES (?, ?, 'video/mp4', 'v.mp4')").run(c, c);
const boundary = '----st' + crypto.randomBytes(6).toString('hex');
const vtt = 'WEBVTT\n\n00:00.000 --> 00:02.000\nHello\n';
const body = Buffer.concat([
Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="subtitle_lang"\r\n\r\nfr\r\n`),
Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="subtitle"; filename="cap.vtt"\r\nContent-Type: text/vtt\r\n\r\n`),
Buffer.from(vtt), Buffer.from(`\r\n--${boundary}--\r\n`),
]);
const res = await new Promise((resolve, reject) => {
const req = http.request(`${base}/${c}/subtitle`, {
method: 'POST',
headers: { 'Content-Type': `multipart/form-data; boundary=${boundary}`, 'Content-Length': body.length },
}, (r) => { let o = ''; r.on('data', (d) => (o += d)); r.on('end', () => resolve({ status: r.statusCode, json: JSON.parse(o) })); });
req.on('error', reject);
req.end(body);
});
assert.equal(res.status, 200);
assert.match(res.json.subtitle_url, /\.vtt$/);
assert.equal(res.json.subtitle_lang, 'fr');
// The file physically exists in the content dir and holds the cue text.
const onDisk = path.join(require('../config').contentDir, res.json.subtitle_url);
assert.ok(fs.existsSync(onDisk));
assert.match(fs.readFileSync(onDisk, 'utf8'), /WEBVTT/);
});
test('a non-.vtt upload to /:id/subtitle is rejected', async () => {
const c = uid('vid2');
db.prepare("INSERT INTO content (id, filename, mime_type, filepath) VALUES (?, ?, 'video/mp4', 'v.mp4')").run(c, c);
const boundary = '----st' + crypto.randomBytes(6).toString('hex');
const body = Buffer.concat([
Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="subtitle"; filename="notsub.txt"\r\nContent-Type: text/plain\r\n\r\n`),
Buffer.from('nope'), Buffer.from(`\r\n--${boundary}--\r\n`),
]);
const status = await new Promise((resolve, reject) => {
const req = http.request(`${base}/${c}/subtitle`, {
method: 'POST',
headers: { 'Content-Type': `multipart/form-data; boundary=${boundary}`, 'Content-Length': body.length },
}, (r) => { r.on('data', () => {}); r.on('end', () => resolve(r.statusCode)); });
req.on('error', reject);
req.end(body);
});
assert.notEqual(status, 200); // multer fileFilter rejects the .txt
});