diff --git a/frontend/js/i18n/en.js b/frontend/js/i18n/en.js index 5c5eb63..e774d58 100644 --- a/frontend/js/i18n/en.js +++ b/frontend/js/i18n/en.js @@ -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 —', diff --git a/frontend/js/i18n/es.js b/frontend/js/i18n/es.js index 0bd48d0..7fcf191 100644 --- a/frontend/js/i18n/es.js +++ b/frontend/js/i18n/es.js @@ -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 —', diff --git a/frontend/js/views/content-library.js b/frontend/js/views/content-library.js index 3725377..9462118 100644 --- a/frontend/js/views/content-library.js +++ b/frontend/js/views/content-library.js @@ -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 `) + .join(''); overlay.innerHTML = ` ` : ''} + ${isYoutube ? ` +
+ +
+ + +
+

${t('content.captions_hint')}

+
+ ` : ''} + ${isUploadedVideo ? ` +
+ + ${contentItem.subtitle_url ? `

${t('content.subtitle_current')}

` : ''} + +
+ + +
+ ${contentItem.subtitle_url ? `` : ''} +

${t('content.subtitle_hint')}

+
+ ` : ''} ${!isRemote ? `
@@ -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(); diff --git a/server/db/database.js b/server/db/database.js index 6d9edaf..a151477 100644 --- a/server/db/database.js +++ b/server/db/database.js @@ -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/) + subtitle_lang for the 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. diff --git a/server/middleware/upload.js b/server/middleware/upload.js index fbd953c..beaef31 100644 --- a/server/middleware/upload.js +++ b/server/middleware/upload.js @@ -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/) 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; diff --git a/server/player/index.html b/server/player/index.html index f844ec0..9213f9d 100644 --- a/server/player/index.html +++ b/server/player/index.html @@ -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'; diff --git a/server/routes/content.js b/server/routes/content.js index 50b018b..462e1b9 100644 --- a/server/routes/content.js +++ b/server/routes/content.js @@ -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/) 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); diff --git a/server/routes/playlists.js b/server/routes/playlists.js index c3f3d94..eb37791 100644 --- a/server/routes/playlists.js +++ b/server/routes/playlists.js @@ -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 diff --git a/server/test/content-subtitles.test.js b/server/test/content-subtitles.test.js new file mode 100644 index 0000000..db5c3b2 --- /dev/null +++ b/server/test/content-subtitles.test.js @@ -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 +});