mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 22:03:13 -06:00
feat(content): unstable-connection mode — cap YouTube at 720p for weak WiFi (#220)
On Android TV with weak/unstable WiFi, YouTube embeds auto-select 1080p+
and buffer/stall. Add a per-item "Unstable connection" flag that biases the
YouTube embed toward a 720p ceiling.
- DB: content.unstable_connection INTEGER NOT NULL DEFAULT 0 (non-destructive,
existing rows unchanged).
- buildSnapshotItems: denormalize the flag into published_snapshot so it
reaches the player (that query enumerates columns, so it had to be added
explicitly — covered by a new test). preview-payload reuses the same query.
- content.js PUT: accept + coerce unstable_connection to 0/1.
- Player: playerVars.vq='hd720' + best-effort setPlaybackQuality('hd720') in
onReady when the flag is set. Both are hints YouTube may still override, but
together they bias the initial selection down to 720p.
- Edit modal: YouTube-only checkbox + hint; en/es i18n.
Scoped to the core ask; the issue's optional "Shorts get inverse hd1080"
refinement is intentionally left out (dubious for signage, and forcing higher
quality on a weak link is the opposite of the goal).
Closes #217
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9d6c3c79b0
commit
ad03a5ec0a
|
|
@ -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 —',
|
||||
|
|
|
|||
|
|
@ -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 —',
|
||||
|
|
|
|||
|
|
@ -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 = `
|
||||
<div class="modal" style="max-width:500px;width:95vw">
|
||||
|
|
@ -571,6 +572,15 @@ function showEditModal(contentItem, onSave) {
|
|||
<input type="datetime-local" id="editExpiresAt" class="input" style="background:var(--bg-input)" value="${toLocalDatetimeInput(contentItem.expires_at)}">
|
||||
<p style="font-size:11px;color:var(--text-muted);margin-top:4px">${t('content.expires_hint')}</p>
|
||||
</div>
|
||||
${isYoutube ? `
|
||||
<div class="form-group">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
<input type="checkbox" id="editUnstableConnection" ${contentItem.unstable_connection ? 'checked' : ''} style="width:auto;margin:0">
|
||||
<span>${t('content.label_unstable_connection')}</span>
|
||||
</label>
|
||||
<p style="font-size:11px;color:var(--text-muted);margin-top:4px">${t('content.unstable_connection_hint')}</p>
|
||||
</div>
|
||||
` : ''}
|
||||
${!isRemote ? `
|
||||
<div class="form-group">
|
||||
<label>${t('content.label_replace_file')}</label>
|
||||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
53
server/test/content-unstable-connection.test.js
Normal file
53
server/test/content-unstable-connection.test.js
Normal file
|
|
@ -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);
|
||||
});
|
||||
Loading…
Reference in a new issue