diff --git a/frontend/js/i18n/en.js b/frontend/js/i18n/en.js index 15ccb2e..5c76885 100644 --- a/frontend/js/i18n/en.js +++ b/frontend/js/i18n/en.js @@ -218,6 +218,8 @@ export default { 'content.label_folder': 'Folder', 'content.label_expires_at': 'Expiry date', '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_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 2b610c8..1eaf072 100644 --- a/frontend/js/i18n/es.js +++ b/frontend/js/i18n/es.js @@ -175,6 +175,8 @@ export default { 'content.label_remote_url_field': 'URL remota', 'content.label_mime_type': 'Tipo MIME', '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_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 104283a..5c4ff8a 100644 --- a/frontend/js/views/content-library.js +++ b/frontend/js/views/content-library.js @@ -528,6 +528,7 @@ function showEditModal(contentItem, onSave) { overlay.style.display = 'flex'; const isRemote = !!contentItem.remote_url; + const isYoutube = contentItem.mime_type === 'video/youtube'; overlay.innerHTML = ` + ${isYoutube ? ` +
+ +

${t('content.unstable_connection_hint')}

+
+ ` : ''} ${!isRemote ? `
@@ -613,6 +623,12 @@ function showEditModal(contentItem, onSave) { const newExpiry = expiryRaw ? Math.floor(new Date(expiryRaw).getTime() / 1000) : null; const curExpiry = contentItem.expires_at != null ? Number(contentItem.expires_at) : null; if (newExpiry !== curExpiry) updateData.expires_at = newExpiry; + // #217: YouTube-only "unstable connection" quality cap. + const unstableEl = overlay.querySelector('#editUnstableConnection'); + if (unstableEl) { + const newUnstable = unstableEl.checked ? 1 : 0; + if (newUnstable !== (contentItem.unstable_connection ? 1 : 0)) updateData.unstable_connection = newUnstable; + } if (Object.keys(updateData).length > 0) { await fetch('/api/content/' + contentItem.id, { diff --git a/server/db/database.js b/server/db/database.js index 6df19fe..6d9edaf 100644 --- a/server/db/database.js +++ b/server/db/database.js @@ -376,6 +376,10 @@ const migrations = [ // Every other existing local user stays 0 -> prompted on first login. "UPDATE users SET email_verified = 1 WHERE auth_provider != 'local'", "UPDATE users SET email_verified = 1 WHERE role = 'platform_admin'", + // #217: per-item "unstable connection" flag. When set, the player caps the YouTube + // 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", ]; // 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/player/index.html b/server/player/index.html index b7133a0..f844ec0 100644 --- a/server/player/index.html +++ b/server/player/index.html @@ -2035,6 +2035,10 @@ playlist: shouldLoop ? videoId : undefined, enablejsapi: 1, origin: window.location.origin, + // #217: cap quality at 720p for items flagged "unstable connection" so weak + // WiFi doesn't stall on an auto-selected 1080p+ stream. vq is a hint the player + // may still override, so we also call setPlaybackQuality in onReady below. + vq: item.unstable_connection ? 'hd720' : undefined, }, events: { onReady: (event) => { @@ -2061,6 +2065,12 @@ }, (duration + 3) * 1000); } } + // #217: best-effort quality cap for weak WiFi. setPlaybackQuality is a + // hint (YouTube may still adapt), but combined with the vq playerVar it + // biases the initial selection toward 720p instead of 1080p+. + if (item.unstable_connection) { + try { event.target.setPlaybackQuality('hd720'); } catch {} + } }, onError: (event) => { if (myGeneration !== ytGeneration) return; diff --git a/server/routes/content.js b/server/routes/content.js index 2d37523..0e3b9e7 100644 --- a/server/routes/content.js +++ b/server/routes/content.js @@ -242,7 +242,7 @@ router.put('/:id', (req, res) => { const content = checkContentWrite(req, res); if (!content) return; - const { filename, mime_type, remote_url, folder, folder_id, expires_at } = req.body; + const { filename, mime_type, remote_url, folder, folder_id, expires_at, unstable_connection } = req.body; const updates = []; const values = []; if (filename !== undefined) { updates.push('filename = ?'); values.push(safeFilename(filename)); } @@ -289,6 +289,12 @@ router.put('/:id', (req, res) => { updates.push('expires_at = ?'); values.push(val); updates.push('is_active = 1'); } + // #217: force a lower YouTube quality ceiling for weak/unstable WiFi. Stored 0/1; + // accepts booleans or 0/1 from the client and coerces to an integer. + if (unstable_connection !== undefined) { + updates.push('unstable_connection = ?'); + values.push(unstable_connection ? 1 : 0); + } if (updates.length > 0) { values.push(req.params.id); diff --git a/server/routes/playlists.js b/server/routes/playlists.js index c0967c8..c3f3d94 100644 --- a/server/routes/playlists.js +++ b/server/routes/playlists.js @@ -68,7 +68,7 @@ function buildSnapshotItems(playlistId) { const items = db.prepare(` 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.duration_sec as content_duration, c.remote_url, c.unstable_connection, 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-unstable-connection.test.js b/server/test/content-unstable-connection.test.js new file mode 100644 index 0000000..ddcff56 --- /dev/null +++ b/server/test/content-unstable-connection.test.js @@ -0,0 +1,53 @@ +'use strict'; + +// #217 unstable-connection quality cap. The player only receives a content field if +// buildSnapshotItems (via publishPlaylist) denormalizes it into published_snapshot — +// that query enumerates columns explicitly, so this guards against the flag silently +// not reaching the player. + +const os = require('node:os'); +const path = require('node:path'); +const crypto = require('node:crypto'); +process.env.DATA_DIR = path.join(os.tmpdir(), 'st-unstable-' + crypto.randomBytes(4).toString('hex')); +process.env.SELF_HOSTED = 'true'; +process.env.NODE_ENV = 'test'; + +const { test, before } = require('node:test'); +const assert = require('node:assert/strict'); +const { db } = require('../db/database'); +const { publishPlaylist } = require('../routes/playlists'); + +const uid = (p) => p + '-' + crypto.randomBytes(4).toString('hex'); + +let USER; +before(() => { + USER = uid('u'); + db.prepare("INSERT INTO users (id, email, password_hash) VALUES (?, ?, 'x')").run(USER, USER + '@t.local'); +}); + +test('published snapshot carries unstable_connection so the player can cap quality', () => { + const capped = uid('capped'), normal = uid('normal'); + db.prepare("INSERT INTO content (id, filename, mime_type, remote_url, unstable_connection) VALUES (?, ?, 'video/youtube', 'https://youtu.be/aaaaaaaaaaa', 1)") + .run(capped, capped); + db.prepare("INSERT INTO content (id, filename, mime_type, remote_url, unstable_connection) VALUES (?, ?, 'video/youtube', 'https://youtu.be/bbbbbbbbbbb', 0)") + .run(normal, normal); + + 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, capped); + db.prepare('INSERT INTO playlist_items (playlist_id, content_id, sort_order) VALUES (?, ?, 1)').run(pl, normal); + + publishPlaylist(pl); + + const snapshot = JSON.parse(db.prepare('SELECT published_snapshot FROM playlists WHERE id = ?').get(pl).published_snapshot); + const byId = Object.fromEntries(snapshot.map(i => [i.content_id, i])); + assert.equal(byId[capped].unstable_connection, 1, 'flagged item keeps the cap in the snapshot'); + assert.equal(byId[normal].unstable_connection, 0, 'unflagged item stays uncapped'); +}); + +test('unstable_connection defaults to 0 for content that never set it', () => { + const c = uid('default'); + db.prepare("INSERT INTO content (id, filename, mime_type) VALUES (?, ?, 'video/youtube')").run(c, c); + const row = db.prepare('SELECT unstable_connection FROM content WHERE id = ?').get(c); + assert.equal(row.unstable_connection, 0); +});