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 ? `
+
+ ` : ''}
${!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);
+});