Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
d4ddcdb
backend-player: add daily playtime limit with per-weekday config
wowa1990 May 6, 2026
fb0ee9f
backend-api: expose /api/playtime endpoint reading player tmpfs state
wowa1990 May 6, 2026
2f07a2a
frontend-box: add playtime remaining chip and blocked overlay
wowa1990 May 6, 2026
4ce1e6a
admin: add Daily Playtime Limit section to MuPi-Conf page
wowa1990 May 6, 2026
c258022
playtime: add grace period so the current track can finish naturally
wowa1990 May 6, 2026
d1e30c9
playtime: tighten resume capture around limit-reached transitions
wowa1990 May 6, 2026
e24c539
playtime: chip is now a global fixed-position badge, faster polling
wowa1990 May 6, 2026
37c29eb
playtime: position chip below toolbar so it doesn't overlap status icons
wowa1990 May 6, 2026
a076d4c
playtime: surface key state transitions in default log + replace emoji
wowa1990 May 6, 2026
ce40626
frontend-box: persist resume on cap from anywhere, not just player page
wowa1990 May 7, 2026
ab82041
frontend-box: stop leaking subscriptions in player.page
wowa1990 May 7, 2026
3a6e456
frontend-box: gate resume saves on actively-listened seconds
wowa1990 May 7, 2026
67506ea
quiet hours: per-weekday playback windows (e.g. homework / bedtime)
wowa1990 May 6, 2026
77ba89b
admin: clarify quiet-hours description (midnight-spanning behavior)
wowa1990 May 6, 2026
df4a122
fix(quiet): suppress quiet-hours state mutation during allowUntil ove…
wowa1990 May 15, 2026
2639634
telegram receiver: enforce chatId-based authorization (security fix)
wowa1990 May 6, 2026
d4fe91c
backend-player: live-reload mupiboxconfig.json (no more pm2 restart o…
wowa1990 May 6, 2026
f0df3dc
telegram parent controls: /status, /extend, /release, /quietnow
wowa1990 May 6, 2026
61bd953
telegram: multi-chat support, /limit set, cap-reached notifications, …
wowa1990 May 7, 2026
5424590
AR5-2 + AR5-7: validate Telegram /vol and /sleep arguments
wowa1990 May 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
247 changes: 246 additions & 1 deletion AdminInterface/www/mupi.php
Original file line number Diff line number Diff line change
Expand Up @@ -441,6 +441,75 @@
$CHANGE_TXT=$CHANGE_TXT."<li>Press Button delay set to ".$_POST['pressDelay']. " seconds</li>";
$change=2;
}
$playtime_changed = false;
if( $_POST['playtime_save'] )
{
if( !isset($data["playtimeLimit"]) || !is_array($data["playtimeLimit"]) )
{
$data["playtimeLimit"] = array(
"enabled" => false,
"resetHour" => 0,
"maxOverrunMinutes" => 10,
"limitsMinutes" => array("mon"=>60,"tue"=>60,"wed"=>60,"thu"=>60,"fri"=>60,"sat"=>60,"sun"=>60),
);
}
if( !isset($data["playtimeLimit"]["limitsMinutes"]) || !is_array($data["playtimeLimit"]["limitsMinutes"]) )
{
$data["playtimeLimit"]["limitsMinutes"] = array("mon"=>60,"tue"=>60,"wed"=>60,"thu"=>60,"fri"=>60,"sat"=>60,"sun"=>60);
}
$data["playtimeLimit"]["enabled"] = (isset($_POST['playtime_enabled']) && $_POST['playtime_enabled'] === '1');
$data["playtimeLimit"]["resetHour"] = max(0, min(23, intval($_POST['playtime_resetHour'])));
$data["playtimeLimit"]["maxOverrunMinutes"] = max(0, min(60, intval($_POST['playtime_maxOverrunMinutes'])));
$playtime_days = array('mon','tue','wed','thu','fri','sat','sun');
foreach( $playtime_days as $d )
{
$field = 'playtime_limit_' . $d;
$val = isset($_POST[$field]) ? intval($_POST[$field]) : 60;
$data["playtimeLimit"]["limitsMinutes"][$d] = max(0, min(1440, $val));
}
$playtime_changed = true;
$CHANGE_TXT = $CHANGE_TXT."<li>Playtime limit settings saved (live, no restart needed)</li>";
$change = 2;
}
if( $_POST['quiethours_save'] )
{
if( !isset($data["quietHours"]) || !is_array($data["quietHours"]) )
{
$data["quietHours"] = array(
"enabled" => false,
"maxOverrunMinutes" => 10,
"schedule" => array("mon"=>array(),"tue"=>array(),"wed"=>array(),"thu"=>array(),"fri"=>array(),"sat"=>array(),"sun"=>array()),
);
}
$data["quietHours"]["enabled"] = (isset($_POST['quiethours_enabled']) && $_POST['quiethours_enabled'] === '1');
$data["quietHours"]["maxOverrunMinutes"] = max(0, min(60, intval($_POST['quiethours_maxOverrunMinutes'])));
$quiethours_days = array('mon','tue','wed','thu','fri','sat','sun');
$quiethours_window_count = 0;
foreach( $quiethours_days as $d )
{
$rawWindows = isset($_POST['quiet_windows'][$d]) && is_array($_POST['quiet_windows'][$d]) ? $_POST['quiet_windows'][$d] : array();
$cleaned = array();
foreach( $rawWindows as $w )
{
if( !is_array($w) ) continue;
$from = isset($w['from']) ? trim($w['from']) : '';
$to = isset($w['to']) ? trim($w['to']) : '';
// Skip incomplete rows (so add-row-then-don't-fill doesn't pollute config).
if( $from === '' || $to === '' ) continue;
if( !preg_match('/^([01][0-9]|2[0-3]):[0-5][0-9]$/', $from) ) continue;
if( !preg_match('/^([01][0-9]|2[0-3]):[0-5][0-9]$/', $to) ) continue;
$entry = array('from' => $from, 'to' => $to);
$label = isset($w['label']) ? trim($w['label']) : '';
if( $label !== '' ) $entry['label'] = $label;
$cleaned[] = $entry;
$quiethours_window_count++;
}
$data["quietHours"]["schedule"][$d] = array_values($cleaned);
}
$playtime_changed = true;
$CHANGE_TXT = $CHANGE_TXT."<li>Quiet hours saved (".$quiethours_window_count." window(s), live, no restart needed)</li>";
$change = 2;
}
if( $data["shim"]["ledPin"]!=$_POST['ledPin'] && $_POST['ledPin'])
{
$data["shim"]["ledPin"]=$_POST['ledPin'];
Expand Down Expand Up @@ -569,7 +638,12 @@
exec("sudo mv /tmp/.mupiboxconfig.json /etc/mupibox/mupiboxconfig.json");
exec("sudo /usr/local/bin/mupibox/./setting_update.sh");
}

// Note: playtime/quiet-hours saves used to trigger `pm2 restart spotify-control` here
// because the player cached mupiboxconfig.json at startup via require(). The player
// now does live-reload via fs.watch, so the restart is no longer needed for those
// sub-blocks — the changes take effect within ~50ms without an audio gap.
// $playtime_changed stays as a flag in case future code wants to react to it.

$CHANGE_TXT=$CHANGE_TXT."</ul></div>";
?>

Expand Down Expand Up @@ -695,6 +769,177 @@
</ul>
</details>

<details id="playtimelimit">
<summary><i class="fa-solid fa-hourglass-half"></i> Daily playtime limit</summary>
<ul>
<li id="li_1">
<h2>About</h2>
<p>Caps the total daily listening time on the box. When the limit is reached, playback stops and new playback is refused until the next day. Set a day to <b>0</b> to block playback completely on that day. Settings take effect after saving (the player is restarted automatically).</p>
</li>
<li id="li_1">
<h2>Status</h2>
<?php
$playtime_enabled_state = ( isset($data["playtimeLimit"]["enabled"]) && $data["playtimeLimit"]["enabled"] ) ? true : false;
$playtime_resetHour = isset($data["playtimeLimit"]["resetHour"]) ? intval($data["playtimeLimit"]["resetHour"]) : 0;
$playtime_limits = isset($data["playtimeLimit"]["limitsMinutes"]) && is_array($data["playtimeLimit"]["limitsMinutes"]) ? $data["playtimeLimit"]["limitsMinutes"] : array();
echo '<p>Currently: <b>'.($playtime_enabled_state ? 'ENABLED' : 'DISABLED').'</b></p>';
?>
<p>Enable / disable the daily limit:</p>
<select name="playtime_enabled">
<option value="1" <?php echo $playtime_enabled_state ? 'selected' : ''; ?>>Enabled</option>
<option value="0" <?php echo !$playtime_enabled_state ? 'selected' : ''; ?>>Disabled</option>
</select>
</li>
<li id="li_1">
<h2>Reset hour (0 - 23)</h2>
<p>Hour of day at which the counter resets to 0. <b>0</b> = midnight. Use e.g. <b>4</b> if you don't want a reset to interrupt late evening listening.</p>
<input type="number" name="playtime_resetHour" min="0" max="23" step="1" value="<?php echo $playtime_resetHour; ?>">
</li>
<li id="li_1">
<h2>Grace period (minutes)</h2>
<p>When the daily limit is reached, allow playback to continue for up to this many additional minutes so the current track can finish naturally. The player stops at the next track boundary (for local files / radio / RSS) or at the latest when this grace runs out. <b>0</b> = stop immediately at the limit. Default: <b>10</b>. Maximum: 60.</p>
<?php $playtime_maxOverrunMinutes = isset($data["playtimeLimit"]["maxOverrunMinutes"]) ? intval($data["playtimeLimit"]["maxOverrunMinutes"]) : 10; ?>
<input type="number" name="playtime_maxOverrunMinutes" min="0" max="60" step="1" value="<?php echo $playtime_maxOverrunMinutes; ?>"> min
</li>
<li id="li_1">
<h2>Daily limit per weekday (minutes)</h2>
<p>Set <b>0</b> to block playback entirely on that day. Maximum 1440 (= 24 h).</p>
<table class="version">
<tr><th>Day</th><th>Minutes per day</th></tr>
<?php
$playtime_day_labels = array(
'mon' => 'Monday',
'tue' => 'Tuesday',
'wed' => 'Wednesday',
'thu' => 'Thursday',
'fri' => 'Friday',
'sat' => 'Saturday',
'sun' => 'Sunday',
);
foreach( $playtime_day_labels as $key => $label )
{
$val = isset($playtime_limits[$key]) ? intval($playtime_limits[$key]) : 60;
echo '<tr><td>'.$label.'</td><td><input type="number" name="playtime_limit_'.$key.'" min="0" max="1440" step="1" value="'.$val.'"> min</td></tr>';
}
?>
</table>
</li>
<li class="buttons">
<input type="hidden" name="form_id" value="37271" />
<input id="saveForm" class="button_text" type="submit" name="playtime_save" value="Save playtime settings" />
</li>
</ul>
</details>

<details id="quiethours">
<summary><i class="fa-solid fa-moon"></i> Quiet hours</summary>
<ul>
<li id="li_1">
<h2>About</h2>
<p>Define time windows per weekday during which playback is automatically blocked (e.g. homework, mealtimes, bedtime). Multiple windows per day are supported.</p>
<p><b>How a window is interpreted:</b></p>
<ul style="margin-left:1.2em;list-style:disc;">
<li>A window <b>belongs to the day it starts on</b>.</li>
<li>If <b>from</b> is later than <b>to</b>, the window automatically continues into the next morning.</li>
<li><b>Example:</b> a single entry on <i>Monday</i> with <code>from 20:00 → to 08:00</code> blocks playback Monday evening <i>and</i> Tuesday morning until 08:00. You do <b>not</b> need a separate Tuesday entry for the same night.</li>
<li>Multiple windows on the same day combine — e.g. add a <code>14:00 → 16:00 (Homework)</code> alongside <code>20:00 → 08:00 (Bedtime)</code> to block both periods.</li>
<li>The optional <b>label</b> is shown to the kid on the block screen (e.g. „Homework", „Bedtime").</li>
</ul>
<p>Settings take effect after saving (the player is restarted automatically).</p>
</li>
<li id="li_1">
<h2>Status</h2>
<?php
$qh_enabled_state = ( isset($data["quietHours"]["enabled"]) && $data["quietHours"]["enabled"] ) ? true : false;
$qh_maxOverrunMinutes = isset($data["quietHours"]["maxOverrunMinutes"]) ? intval($data["quietHours"]["maxOverrunMinutes"]) : 10;
$qh_schedule = isset($data["quietHours"]["schedule"]) && is_array($data["quietHours"]["schedule"]) ? $data["quietHours"]["schedule"] : array();
echo '<p>Currently: <b>'.($qh_enabled_state ? 'ENABLED' : 'DISABLED').'</b></p>';
?>
<p>Enable / disable quiet hours:</p>
<select name="quiethours_enabled">
<option value="1" <?php echo $qh_enabled_state ? 'selected' : ''; ?>>Enabled</option>
<option value="0" <?php echo !$qh_enabled_state ? 'selected' : ''; ?>>Disabled</option>
</select>
</li>
<li id="li_1">
<h2>Grace period (minutes)</h2>
<p>When a quiet window starts, allow up to this many additional minutes for the current track to finish naturally. <b>0</b> = stop immediately at the window boundary. Default: <b>10</b>. Maximum: 60.</p>
<input type="number" name="quiethours_maxOverrunMinutes" min="0" max="60" step="1" value="<?php echo $qh_maxOverrunMinutes; ?>"> min
</li>
<li id="li_1">
<h2>Windows per weekday</h2>
<p>Use „+ Add window" to add another row to a day. Empty rows are ignored on save.</p>
<?php
$qh_day_labels = array(
'mon' => 'Monday',
'tue' => 'Tuesday',
'wed' => 'Wednesday',
'thu' => 'Thursday',
'fri' => 'Friday',
'sat' => 'Saturday',
'sun' => 'Sunday',
);
foreach( $qh_day_labels as $key => $label ) {
$windows = isset($qh_schedule[$key]) && is_array($qh_schedule[$key]) ? $qh_schedule[$key] : array();
echo '<div class="quiet-day-block" style="margin-top:1em;padding:0.5em;border:1px solid #ddd;border-radius:4px;">';
echo '<b>'.$label.'</b>';
echo '<table class="quiet-windows-table" id="quiet-windows-'.$key.'" style="width:100%;margin-top:0.4em;">';
echo '<tr><th>From</th><th>To</th><th>Label (optional)</th><th></th></tr>';
$idx = 0;
foreach( $windows as $w ) {
if( !is_array($w) ) continue;
$wFrom = htmlspecialchars(isset($w['from']) ? $w['from'] : '');
$wTo = htmlspecialchars(isset($w['to']) ? $w['to'] : '');
$wLabel = htmlspecialchars(isset($w['label']) ? $w['label'] : '');
echo '<tr class="quiet-window-row">';
echo '<td><input type="time" name="quiet_windows['.$key.']['.$idx.'][from]" value="'.$wFrom.'"></td>';
echo '<td><input type="time" name="quiet_windows['.$key.']['.$idx.'][to]" value="'.$wTo.'"></td>';
echo '<td><input type="text" name="quiet_windows['.$key.']['.$idx.'][label]" value="'.$wLabel.'" placeholder="e.g. Bedtime"></td>';
echo '<td><button type="button" class="button_text_red" onclick="removeQuietWindow(this)">×</button></td>';
echo '</tr>';
$idx++;
}
echo '</table>';
echo '<button type="button" class="button_text" onclick="addQuietWindow(\''.$key.'\')" style="margin-top:0.4em;">+ Add window</button>';
echo '</div>';
}
?>
</li>
<li class="buttons">
<input type="hidden" name="form_id" value="37271" />
<input id="saveForm" class="button_text" type="submit" name="quiethours_save" value="Save quiet hours" />
</li>
</ul>
</details>

<script>
function addQuietWindow(day) {
var table = document.getElementById('quiet-windows-' + day);
// Determine next index by counting existing rows (excluding header).
var existingRows = table.querySelectorAll('tr.quiet-window-row');
var nextIdx = 0;
existingRows.forEach(function(r){
var input = r.querySelector('input[name^="quiet_windows[' + day + ']["]');
if (input) {
var m = input.name.match(/\[(\d+)\]/);
if (m) nextIdx = Math.max(nextIdx, parseInt(m[1]) + 1);
}
});
var row = document.createElement('tr');
row.className = 'quiet-window-row';
row.innerHTML =
'<td><input type="time" name="quiet_windows[' + day + '][' + nextIdx + '][from]"></td>' +
'<td><input type="time" name="quiet_windows[' + day + '][' + nextIdx + '][to]"></td>' +
'<td><input type="text" name="quiet_windows[' + day + '][' + nextIdx + '][label]" placeholder="e.g. Bedtime"></td>' +
'<td><button type="button" class="button_text_red" onclick="removeQuietWindow(this)">×</button></td>';
table.appendChild(row);
}
function removeQuietWindow(btn) {
var row = btn.closest('tr.quiet-window-row');
if (row && row.parentNode) row.parentNode.removeChild(row);
}
</script>

<details id="systemsettings">
<summary><i class="fa-solid fa-screwdriver-wrench"></i> System settings</summary>
<ul>
Expand Down
108 changes: 99 additions & 9 deletions AdminInterface/www/smart.php
Original file line number Diff line number Diff line change
Expand Up @@ -134,14 +134,56 @@
{
$command="sudo bash -c '/usr/local/bin/mupibox/./telegram_set_deviceid.sh'";
exec($command, $output);
$data["telegram"]["chatId"]=$output[0];
$generated_id = trim($output[0]);
// Append the freshly-detected chat to the existing list rather than
// overwriting it. New format: array of {id, label?} objects. Old
// format (single string) is migrated to the new format on save.
$existing = $data["telegram"]["chatId"] ?? "";
if (is_string($existing) || is_numeric($existing)) {
$existing = trim((string)$existing);
$existing = ($existing === "") ? array() : array(array("id" => $existing));
}
if (!is_array($existing)) {
$existing = array();
}
$already = false;
foreach ($existing as $entry) {
$entry_id = is_array($entry) ? ($entry["id"] ?? "") : (string)$entry;
if ((string)$entry_id === $generated_id) { $already = true; break; }
}
if (!$already && $generated_id !== "" && $generated_id !== "null") {
$existing[] = array("id" => $generated_id, "label" => "");
$CHANGE_TXT=$CHANGE_TXT."<li>Detected chat id ".$generated_id." added to the list.</li>";
} else {
$CHANGE_TXT=$CHANGE_TXT."<li>Telegram chat id detection: ".($generated_id === "" || $generated_id === "null" ? "no chat detected — write to your bot first" : "chat id already known")."</li>";
}
$data["telegram"]["chatId"] = $existing;
$change=3;
$CHANGE_TXT=$CHANGE_TXT."<li>Telegram Chat ID generation finished...</li>";
}

if( $_POST['change_telegram'] )
{
$data["telegram"]["chatId"]=$_POST['telegram_chatId'];
// New form: parallel arrays telegram_chatId_id[] + telegram_chatId_label[]
// (one row per chat). Filter out empty rows and store as array of
// {id, label?} objects. Falls back to the legacy single-input field
// if the array fields aren't posted.
$ids = $_POST['telegram_chatId_id'] ?? null;
$labels = $_POST['telegram_chatId_label'] ?? null;
if (is_array($ids)) {
$normalized = array();
foreach ($ids as $i => $raw) {
$id = trim((string)$raw);
if ($id === "") continue;
$entry = array("id" => $id);
$lbl = isset($labels[$i]) ? trim((string)$labels[$i]) : "";
if ($lbl !== "") $entry["label"] = $lbl;
$normalized[] = $entry;
}
$data["telegram"]["chatId"] = $normalized;
} else {
// Legacy single-input fallback
$data["telegram"]["chatId"] = $_POST['telegram_chatId'] ?? "";
}
$data["telegram"]["token"]=$_POST['telegram_token'];
if($_POST['telegram_active'])
{
Expand Down Expand Up @@ -378,14 +420,62 @@
</li>

<li id="li_1" >
<label class="description" for="telegram_chatId">Telegram ChatID</label>
<div>
<input id="telegram_chatId" name="telegram_chatId" class="element text medium" type="text" maxlength="255" value="<?php
print $data["telegram"]["chatId"];
?>"/>
</div><p class="guidelines" id="guide_1"><small>Please enter your telegram ChatId.</small></p>
<label class="description">Telegram Chat IDs</label>
<div id="telegram_chatId_list">
<?php
// Normalize stored value to a list of {id, label} pairs for display.
$entries = array();
$stored = $data["telegram"]["chatId"] ?? "";
if (is_string($stored) || is_numeric($stored)) {
$s = trim((string)$stored);
if ($s !== "") $entries[] = array("id" => $s, "label" => "");
} elseif (is_array($stored)) {
foreach ($stored as $entry) {
if (is_array($entry)) {
$id = trim((string)($entry["id"] ?? ""));
if ($id === "") continue;
$entries[] = array("id" => $id, "label" => trim((string)($entry["label"] ?? "")));
} elseif (is_string($entry) || is_numeric($entry)) {
$id = trim((string)$entry);
if ($id !== "") $entries[] = array("id" => $id, "label" => "");
}
}
}
if (empty($entries)) {
// One empty row so the user has somewhere to type
$entries[] = array("id" => "", "label" => "");
}
foreach ($entries as $entry) {
echo '<div class="telegram_chatId_row" style="display:flex;gap:0.5em;margin-bottom:0.3em;align-items:center;">';
echo '<input name="telegram_chatId_id[]" type="text" maxlength="64" placeholder="Chat ID (z. B. -1001234567890)" style="flex:1;" value="'.htmlspecialchars($entry["id"], ENT_QUOTES).'"/>';
echo '<input name="telegram_chatId_label[]" type="text" maxlength="64" placeholder="Label (optional, z. B. Familie)" style="flex:1;" value="'.htmlspecialchars($entry["label"], ENT_QUOTES).'"/>';
echo '<button type="button" onclick="removeTelegramChat(this)">Entfernen</button>';
echo '</div>';
}
?>
</div>
<button type="button" onclick="addTelegramChat()" style="margin-top:0.5em;">+ Chat hinzufügen</button>
<p class="guidelines" id="guide_1"><small>Eine Zeile pro Chat oder Gruppe. Label ist optional und nur fürs eigene Wiedererkennen. Leere Zeilen werden beim Speichern verworfen. „Generate Telegram Chat ID" hängt einen neu erkannten Chat an die Liste an.</small></p>
</li>

<script>
function addTelegramChat() {
var list = document.getElementById('telegram_chatId_list');
var row = document.createElement('div');
row.className = 'telegram_chatId_row';
row.style.cssText = 'display:flex;gap:0.5em;margin-bottom:0.3em;align-items:center;';
row.innerHTML =
'<input name="telegram_chatId_id[]" type="text" maxlength="64" placeholder="Chat ID (z. B. -1001234567890)" style="flex:1;"/>' +
'<input name="telegram_chatId_label[]" type="text" maxlength="64" placeholder="Label (optional, z. B. Familie)" style="flex:1;"/>' +
'<button type="button" onclick="removeTelegramChat(this)">Entfernen</button>';
list.appendChild(row);
}
function removeTelegramChat(btn) {
var row = btn.closest('.telegram_chatId_row');
if (row && row.parentNode) row.parentNode.removeChild(row);
}
</script>


<li class="buttons">
<input id="saveForm" class="button_text" type="submit" name="change_telegram" value="Save Telegram Configuration" />
Expand Down
Loading