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 0 to block playback completely on that day. Settings take effect after saving (the player is restarted automatically).
From c2580228019d6b3acf6e8678a81fd00ce3049027 Mon Sep 17 00:00:00 2001
From: Waldemar Berg
Date: Wed, 6 May 2026 15:33:17 +0200
Subject: [PATCH 05/20] playtime: add grace period so the current track can
finish naturally
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Previously hitting the daily limit ripped the music away mid-track.
Now the limit-reached transition enters a "grace" state instead of
stopping immediately:
- mplayer (local files / radio / RSS): playback continues until the
next track-change OR playlist-finish event, whichever comes first;
the player stops at that natural break point.
- Spotify: there is no track-change event from the Web API without
extra polling, so for Spotify the grace timeout is the only stop
trigger.
- Hard cap: in either case, after maxOverrunMinutes (default 10,
configurable 0-60 in mupiboxconfig.json under playtimeLimit) the
player force-stops regardless of where playback is.
Schema change: PlaytimeStatus now exposes a state field
('normal' | 'grace' | 'blocked') instead of a boolean blocked. The
frontend chip stays visible only in 'normal'; the full-screen overlay
shows only in 'blocked' (during grace, the kid still hears music
finishing — overlay would be confusing).
The catch-all command handler still refuses new play/resume/skip
during grace too — only the *currently playing* track is allowed to
keep going.
Setting it to 0 reproduces the previous behaviour (immediate hard
stop at the limit) for parents who prefer that.
Admin UI: new "Grace period (minutes)" field in mupi.php.
---
AdminInterface/www/mupi.php | 8 ++
config/templates/mupiboxconfig.json | 1 +
src/backend-api/src/models/playtime.model.ts | 9 +-
src/backend-player/src/spotify-control.js | 96 ++++++++++++++-----
src/frontend-box/src/app/app.component.ts | 2 +-
.../playtime-chip/playtime-chip.component.ts | 2 +-
src/frontend-box/src/app/playtime.model.ts | 5 +-
7 files changed, 96 insertions(+), 27 deletions(-)
diff --git a/AdminInterface/www/mupi.php b/AdminInterface/www/mupi.php
index e353090c..aa1ac6e8 100644
--- a/AdminInterface/www/mupi.php
+++ b/AdminInterface/www/mupi.php
@@ -449,6 +449,7 @@
$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),
);
}
@@ -458,6 +459,7 @@
}
$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 )
{
@@ -754,6 +756,12 @@
Hour of day at which the counter resets to 0. 0 = midnight. Use e.g. 4 if you don't want a reset to interrupt late evening listening.
+
+
Grace period (minutes)
+
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. 0 = stop immediately at the limit. Default: 10. Maximum: 60.
+
+ min
+
Daily limit per weekday (minutes)
Set 0 to block playback entirely on that day. Maximum 1440 (= 24 h).
diff --git a/config/templates/mupiboxconfig.json b/config/templates/mupiboxconfig.json
index 6b67b6e8..b7991001 100644
--- a/config/templates/mupiboxconfig.json
+++ b/config/templates/mupiboxconfig.json
@@ -268,6 +268,7 @@
"playtimeLimit": {
"enabled": false,
"resetHour": 0,
+ "maxOverrunMinutes": 10,
"limitsMinutes": {
"mon": 60,
"tue": 60,
diff --git a/src/backend-api/src/models/playtime.model.ts b/src/backend-api/src/models/playtime.model.ts
index 40948050..2bf775ec 100644
--- a/src/backend-api/src/models/playtime.model.ts
+++ b/src/backend-api/src/models/playtime.model.ts
@@ -5,9 +5,15 @@ export type PlaytimeLimitsMinutes = Partial>
export interface PlaytimeLimitConfig {
enabled: boolean
resetHour?: number
+ maxOverrunMinutes?: number
limitsMinutes?: PlaytimeLimitsMinutes
}
+// 'normal' → under daily limit, playback unrestricted
+// 'grace' → over limit, current track allowed to finish; new commands blocked
+// 'blocked' → fully stopped, frontend overlays the screen
+export type PlaytimePlayState = 'normal' | 'grace' | 'blocked'
+
export type PlaytimeStatus = PlaytimeStatusEnabled | PlaytimeStatusDisabled
export interface PlaytimeStatusDisabled {
@@ -16,11 +22,12 @@ export interface PlaytimeStatusDisabled {
export interface PlaytimeStatusEnabled {
enabled: true
+ state: PlaytimePlayState
date: string
dayKey: PlaytimeDayKey
limitMinutes: number
usedSeconds: number
remainingSeconds: number
- blocked: boolean
+ graceEndsInSeconds: number
resetHour: number
}
diff --git a/src/backend-player/src/spotify-control.js b/src/backend-player/src/spotify-control.js
index f89083c2..87f53938 100644
--- a/src/backend-player/src/spotify-control.js
+++ b/src/backend-player/src/spotify-control.js
@@ -122,6 +122,20 @@ player.on('track-change', () => {
cmdCall('/usr/bin/python3 /usr/local/bin/mupibox/telegram_Track_Local.py')
})
+// Playtime grace period: stop at the next natural mplayer break point
+// (start of next track, or end of playlist). Spotify doesn't surface these
+// events, so for Spotify the grace timeout in playtimeTick() is the only stop trigger.
+player.on('track-change', () => {
+ if (playtimeState.state === 'grace') {
+ finalizePlaytimeBlock('next track would start during grace period')
+ }
+})
+player.on('playlist-finish', () => {
+ if (playtimeState.state === 'grace') {
+ finalizePlaytimeBlock('playlist finished during grace period')
+ }
+})
+
setInterval(() => {
const cmdVolume = "/usr/bin/amixer sget Master | grep 'Right:'"
const exec = require('node:child_process').exec
@@ -194,9 +208,16 @@ const PLAYTIME_CHECKPOINT_INTERVAL_MS = 60_000
function readPlaytimeConfig() {
const raw = muPiBoxConfig?.playtimeLimit || {}
const resetHour = Number.isInteger(raw.resetHour) && raw.resetHour >= 0 && raw.resetHour < 24 ? raw.resetHour : 0
+ // Grace period in minutes after the limit is reached during which playback may
+ // continue (current track allowed to finish). 0 = stop immediately at the limit.
+ const maxOverrunMinutes =
+ Number.isInteger(raw.maxOverrunMinutes) && raw.maxOverrunMinutes >= 0 && raw.maxOverrunMinutes <= 60
+ ? raw.maxOverrunMinutes
+ : 10
return {
enabled: raw.enabled === true,
resetHour,
+ maxOverrunMinutes,
limitsMinutes: { ...PLAYTIME_DEFAULT_LIMITS, ...(raw.limitsMinutes || {}) },
}
}
@@ -217,11 +238,13 @@ function isActuallyPlaying() {
return false
}
+// state machine: 'normal' (under limit) → 'grace' (over limit, current track finishing) → 'blocked' (stopped)
const playtimeState = {
date: '',
dayKey: 'mon',
usedSeconds: 0,
- blocked: false,
+ state: 'normal',
+ graceEndsAt: null,
}
let playtimeLastCheckpointAt = 0
let playtimeLastCheckpointSeconds = -1
@@ -251,14 +274,17 @@ function writePlaytimeWorking() {
return
}
const limit = cfg.limitsMinutes[playtimeState.dayKey] ?? 60
+ const graceEndsInSeconds =
+ playtimeState.graceEndsAt !== null ? Math.max(0, Math.ceil((playtimeState.graceEndsAt - Date.now()) / 1000)) : 0
const payload = {
enabled: true,
+ state: playtimeState.state,
date: playtimeState.date,
dayKey: playtimeState.dayKey,
limitMinutes: limit,
usedSeconds: playtimeState.usedSeconds,
remainingSeconds: Math.max(0, limit * 60 - playtimeState.usedSeconds),
- blocked: playtimeState.blocked,
+ graceEndsInSeconds,
resetHour: cfg.resetHour,
}
fs.writeFile(PLAYTIME_WORKING_PATH, JSON.stringify(payload), () => {})
@@ -277,12 +303,24 @@ function writePlaytimeCheckpoint() {
playtimeLastCheckpointSeconds = playtimeState.usedSeconds
}
+// Used by the catch-all to decide whether to refuse new play/resume/skip commands.
+// During grace, the *current* track is allowed to finish but no new playback may start.
function isPlaytimeBlocked() {
- const cfg = readPlaytimeConfig()
- if (!cfg.enabled) return false
- const today = getLogicalDay(new Date(), cfg.resetHour)
- const limit = cfg.limitsMinutes[today.dayKey] ?? 60
- return playtimeState.usedSeconds >= limit * 60
+ return playtimeState.state === 'grace' || playtimeState.state === 'blocked'
+}
+
+// Transition to fully-stopped state. Called from the tick on grace timeout, from the
+// mplayer track-change/playlist-finish handlers, or directly when grace=0.
+function finalizePlaytimeBlock(reason) {
+ log.info(`${new Date().toLocaleString()}: [Playtime] Finalizing block (${reason})`)
+ playtimeState.state = 'blocked'
+ playtimeState.graceEndsAt = null
+ try {
+ stop()
+ } catch (e) {
+ log.error(`${new Date().toLocaleString()}: [Playtime] Error stopping playback:`, e)
+ }
+ writePlaytimeCheckpoint()
}
// Commands that *start or resume* playback. These get blocked when the daily cap is hit.
@@ -306,18 +344,22 @@ function isPlayInitiatingCommand(command) {
function playtimeTick() {
const cfg = readPlaytimeConfig()
if (!cfg.enabled) {
- if (playtimeState.blocked) playtimeState.blocked = false
+ if (playtimeState.state !== 'normal' || playtimeState.graceEndsAt !== null) {
+ playtimeState.state = 'normal'
+ playtimeState.graceEndsAt = null
+ }
writePlaytimeWorking()
return
}
const now = new Date()
const today = getLogicalDay(now, cfg.resetHour)
- // Day rollover: reset counter
+ // Day rollover: reset counter and state
if (today.dateStr !== playtimeState.date) {
playtimeState.date = today.dateStr
playtimeState.dayKey = today.dayKey
playtimeState.usedSeconds = 0
- playtimeState.blocked = false
+ playtimeState.state = 'normal'
+ playtimeState.graceEndsAt = null
writePlaytimeCheckpoint()
log.info(`${now.toLocaleString()}: [Playtime] New day: ${today.dateStr} (${today.dayKey})`)
}
@@ -325,22 +367,30 @@ function playtimeTick() {
if (isActuallyPlaying()) {
playtimeState.usedSeconds++
}
- // Check the limit
+ // State transitions
const limit = cfg.limitsMinutes[today.dayKey] ?? 60
const limitSeconds = limit * 60
- const wasBlocked = playtimeState.blocked
- playtimeState.blocked = playtimeState.usedSeconds >= limitSeconds
- // Just-blocked transition: stop playback once
- if (playtimeState.blocked && !wasBlocked) {
- log.info(
- `${now.toLocaleString()}: [Playtime] Daily limit reached (${limit} min for ${today.dayKey}). Stopping playback.`,
- )
- try {
- stop()
- } catch (e) {
- log.error(`${now.toLocaleString()}: [Playtime] Error stopping playback:`, e)
+ const limitReached = playtimeState.usedSeconds >= limitSeconds
+ if (limitReached) {
+ if (playtimeState.state === 'normal') {
+ // Just-reached transition
+ const overrunMs = cfg.maxOverrunMinutes * 60 * 1000
+ if (overrunMs > 0) {
+ playtimeState.state = 'grace'
+ playtimeState.graceEndsAt = Date.now() + overrunMs
+ log.info(
+ `${now.toLocaleString()}: [Playtime] Daily limit reached (${limit} min for ${today.dayKey}). Entering grace period (max ${cfg.maxOverrunMinutes} min until current track ends).`,
+ )
+ writePlaytimeCheckpoint()
+ } else {
+ finalizePlaytimeBlock(`limit reached (${limit} min, no grace configured)`)
+ }
+ } else if (playtimeState.state === 'grace') {
+ if (playtimeState.graceEndsAt !== null && Date.now() >= playtimeState.graceEndsAt) {
+ finalizePlaytimeBlock(`grace period expired (${cfg.maxOverrunMinutes} min)`)
+ }
}
- writePlaytimeCheckpoint()
+ // else 'blocked': stay blocked
}
// Working state: every tick (tmpfs, no SD wear)
writePlaytimeWorking()
diff --git a/src/frontend-box/src/app/app.component.ts b/src/frontend-box/src/app/app.component.ts
index 84ed4cae..50b8388a 100644
--- a/src/frontend-box/src/app/app.component.ts
+++ b/src/frontend-box/src/app/app.component.ts
@@ -38,7 +38,7 @@ export class AppComponent {
)
this.playtimeBlocked = computed(() => {
const s = playtimeService.status()
- return s.enabled === true && s.blocked
+ return s.enabled === true && s.state === 'blocked'
})
}
}
diff --git a/src/frontend-box/src/app/playtime-chip/playtime-chip.component.ts b/src/frontend-box/src/app/playtime-chip/playtime-chip.component.ts
index 21befc08..dab010d4 100644
--- a/src/frontend-box/src/app/playtime-chip/playtime-chip.component.ts
+++ b/src/frontend-box/src/app/playtime-chip/playtime-chip.component.ts
@@ -18,7 +18,7 @@ export class PlaytimeChipComponent {
protected readonly visible: Signal = computed(() => {
const s = this.playtimeService.status()
- return s.enabled === true && !s.blocked
+ return s.enabled === true && s.state === 'normal'
})
protected readonly remainingMinutes: Signal = computed(() => {
diff --git a/src/frontend-box/src/app/playtime.model.ts b/src/frontend-box/src/app/playtime.model.ts
index 53d76c83..c9e933a5 100644
--- a/src/frontend-box/src/app/playtime.model.ts
+++ b/src/frontend-box/src/app/playtime.model.ts
@@ -1,5 +1,7 @@
export type PlaytimeDayKey = 'mon' | 'tue' | 'wed' | 'thu' | 'fri' | 'sat' | 'sun'
+export type PlaytimePlayState = 'normal' | 'grace' | 'blocked'
+
export type PlaytimeStatus = PlaytimeStatusEnabled | PlaytimeStatusDisabled
export interface PlaytimeStatusDisabled {
@@ -8,11 +10,12 @@ export interface PlaytimeStatusDisabled {
export interface PlaytimeStatusEnabled {
enabled: true
+ state: PlaytimePlayState
date: string
dayKey: PlaytimeDayKey
limitMinutes: number
usedSeconds: number
remainingSeconds: number
- blocked: boolean
+ graceEndsInSeconds: number
resetHour: number
}
From d1e30c98de0b5aa1131654ad68803ca71b59e99c Mon Sep 17 00:00:00 2001
From: Waldemar Berg
Date: Wed, 6 May 2026 15:35:24 +0200
Subject: [PATCH 06/20] playtime: tighten resume capture around limit-reached
transitions
The existing resume save in PlayerPage runs once every 30 seconds,
which is fine for normal listening but means the recorded resume
position can be up to half a minute behind the actual stop point
when the playtime limit cuts in.
Now the player page also:
- saves immediately when the playtime state transitions into 'grace'
(so the resume entry reflects roughly where the limit was hit), and
again on the transition into 'blocked' (capturing the actual final
stop position after the grace period or hard cap)
- saves every 5 seconds in the last minute before the limit, so the
grace-entry save above isn't itself ~30 s stale
Only kicks in while the player page is open. For background playback
from the home page, the existing 30 s cadence still applies (same
behavior as before).
---
.../src/app/player/player.page.ts | 29 +++++++++++++++++++
1 file changed, 29 insertions(+)
diff --git a/src/frontend-box/src/app/player/player.page.ts b/src/frontend-box/src/app/player/player.page.ts
index 775025a1..73738aee 100644
--- a/src/frontend-box/src/app/player/player.page.ts
+++ b/src/frontend-box/src/app/player/player.page.ts
@@ -40,6 +40,8 @@ import type { Media } from '../media'
import { MediaService } from '../media.service'
import { MupiHatIconComponent } from '../mupihat-icon/mupihat-icon.component'
import { PlayerCmds, PlayerService } from '../player.service'
+import type { PlaytimePlayState } from '../playtime.model'
+import { PlaytimeService } from '../playtime.service'
import { SpotifyService } from '../spotify.service'
@Component({
@@ -86,6 +88,9 @@ export class PlayerPage implements OnInit {
progress = 0
shufflechanged = 0
tmpProgressTime = 0
+ // Tracks the playtime state across ticks so we can detect transitions
+ // (normal -> grace, grace -> blocked, etc.) and persist resume on time.
+ private prevPlaytimeState: PlaytimePlayState | 'unknown' = 'unknown'
public readonly spotify$: Observable
public readonly local$: Observable
@@ -97,6 +102,7 @@ export class PlayerPage implements OnInit {
private navController: NavController,
private playerService: PlayerService,
private spotifyService: SpotifyService,
+ private playtimeService: PlaytimeService,
) {
this.spotify$ = this.mediaService.current$
this.local$ = this.mediaService.local$
@@ -204,6 +210,7 @@ export class PlayerPage implements OnInit {
this.saveResumeFiles()
}
}
+ this.checkPlaytimeForResume()
if (this.media.type === 'spotify') {
const seek = this.currentPlayedSpotify?.progress_ms || 0
@@ -350,6 +357,28 @@ export class PlayerPage implements OnInit {
}
}
+ // The 30s saveResumeFiles cadence in updateProgress() is fine for normal use, but it
+ // can be up to 30 seconds stale when the playtime limit cuts playback off. Save
+ // immediately on the entry transition to grace and to blocked so the resume entry
+ // captures (close to) the actual stop position. In the last minute before the limit
+ // is reached, also save more frequently so the grace-entry save isn't itself stale.
+ private checkPlaytimeForResume() {
+ const status = this.playtimeService.status()
+ if (!status.enabled) {
+ this.prevPlaytimeState = 'unknown'
+ return
+ }
+ const cur = status.state
+ if (this.prevPlaytimeState !== 'unknown' && cur !== this.prevPlaytimeState) {
+ if (cur === 'grace' || cur === 'blocked') {
+ this.saveResumeFiles()
+ }
+ } else if (cur === 'normal' && this.playing && status.remainingSeconds <= 60 && this.resumeTimer % 5 === 0) {
+ this.saveResumeFiles()
+ }
+ this.prevPlaytimeState = cur
+ }
+
saveResumeFiles() {
this.resumemedia = Object.assign({}, this.media)
this.mediaService.current$.subscribe((spotify) => {
From e24c539560a4c384961c5228d674f5f45c732441 Mon Sep 17 00:00:00 2001
From: Waldemar Berg
Date: Wed, 6 May 2026 19:47:13 +0200
Subject: [PATCH 07/20] playtime: chip is now a global fixed-position badge,
faster polling
The chip was scoped to the home toolbar, so during playback (player
page, medialist, etc.) the kid had no remaining-time indicator at all.
Changes:
- Move from home.page.html into the AppComponent
so it renders on every page.
- Switch chip CSS from inline-flex to position:fixed top-right with a
high-but-below-overlay z-index (9000 vs overlay's 9999) and
pointer-events: none so it never intercepts touches on whatever
page is underneath.
- Drop poll interval from 30s to 5s. The endpoint just reads a tmpfs
file so the extra requests are essentially free, and 30s was
visibly stale around limit transitions (chip would briefly show
the old "1 min" remaining after the player had already stopped).
---
src/frontend-box/src/app/app.component.html | 1 +
src/frontend-box/src/app/app.component.ts | 3 ++-
src/frontend-box/src/app/home/home.page.html | 1 -
src/frontend-box/src/app/home/home.page.ts | 2 --
.../playtime-chip/playtime-chip.component.scss | 16 +++++++++++-----
src/frontend-box/src/app/playtime.service.ts | 4 +++-
6 files changed, 17 insertions(+), 10 deletions(-)
diff --git a/src/frontend-box/src/app/app.component.html b/src/frontend-box/src/app/app.component.html
index a18b4230..fbc30220 100644
--- a/src/frontend-box/src/app/app.component.html
+++ b/src/frontend-box/src/app/app.component.html
@@ -2,6 +2,7 @@
@if (monitorOff()) {
}
+
@if (playtimeBlocked()) {
}
diff --git a/src/frontend-box/src/app/app.component.ts b/src/frontend-box/src/app/app.component.ts
index 50b8388a..afb907b2 100644
--- a/src/frontend-box/src/app/app.component.ts
+++ b/src/frontend-box/src/app/app.component.ts
@@ -9,12 +9,13 @@ import { ExternalPlaybackNavigatorService } from './external-playback-navigator.
import { Monitor } from './monitor'
import { PlaytimeService } from './playtime.service'
import { PlaytimeBlockedOverlayComponent } from './playtime-blocked-overlay/playtime-blocked-overlay.component'
+import { PlaytimeChipComponent } from './playtime-chip/playtime-chip.component'
@Component({
selector: 'app-root',
templateUrl: 'app.component.html',
styleUrls: ['app.component.scss'],
- imports: [IonApp, IonRouterOutlet, PlaytimeBlockedOverlayComponent],
+ imports: [IonApp, IonRouterOutlet, PlaytimeBlockedOverlayComponent, PlaytimeChipComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppComponent {
diff --git a/src/frontend-box/src/app/home/home.page.html b/src/frontend-box/src/app/home/home.page.html
index a7667024..5d19fd43 100644
--- a/src/frontend-box/src/app/home/home.page.html
+++ b/src/frontend-box/src/app/home/home.page.html
@@ -18,7 +18,6 @@
-
@if (isOnline()) {
diff --git a/src/frontend-box/src/app/home/home.page.ts b/src/frontend-box/src/app/home/home.page.ts
index 565fdacf..b0f702c9 100644
--- a/src/frontend-box/src/app/home/home.page.ts
+++ b/src/frontend-box/src/app/home/home.page.ts
@@ -28,7 +28,6 @@ import { LoadingComponent } from '../loading/loading.component'
import type { CategoryType } from '../media'
import { MediaService } from '../media.service'
import { MupiHatIconComponent } from '../mupihat-icon/mupihat-icon.component'
-import { PlaytimeChipComponent } from '../playtime-chip/playtime-chip.component'
import { SwiperComponent, SwiperData } from '../swiper/swiper.component'
import { SwiperIonicEventsHelper } from '../swiper/swiper-ionic-events-helper'
@@ -38,7 +37,6 @@ import { SwiperIonicEventsHelper } from '../swiper/swiper-ionic-events-helper'
styleUrls: ['home.page.scss'],
imports: [
MupiHatIconComponent,
- PlaytimeChipComponent,
LoadingComponent,
IonHeader,
IonToolbar,
diff --git a/src/frontend-box/src/app/playtime-chip/playtime-chip.component.scss b/src/frontend-box/src/app/playtime-chip/playtime-chip.component.scss
index 3713d788..98f9ddb4 100644
--- a/src/frontend-box/src/app/playtime-chip/playtime-chip.component.scss
+++ b/src/frontend-box/src/app/playtime-chip/playtime-chip.component.scss
@@ -1,29 +1,35 @@
.playtime-chip {
+ position: fixed;
+ top: 6px;
+ right: 8px;
+ z-index: 9000;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
- margin-right: 8px;
border-radius: 14px;
font-size: 14px;
font-weight: 500;
- background: rgba(255, 255, 255, 0.15);
- color: rgba(255, 255, 255, 0.9);
+ background: rgba(0, 0, 0, 0.55);
+ color: rgba(255, 255, 255, 0.95);
+ backdrop-filter: blur(4px);
+ -webkit-backdrop-filter: blur(4px);
user-select: none;
pointer-events: none;
white-space: nowrap;
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.25);
ion-icon {
font-size: 16px;
}
&[data-level='warning'] {
- background: rgba(255, 193, 7, 0.85);
+ background: rgba(255, 193, 7, 0.9);
color: #1a1a1a;
}
&[data-level='critical'] {
- background: rgba(244, 67, 54, 0.9);
+ background: rgba(244, 67, 54, 0.92);
color: #ffffff;
}
}
diff --git a/src/frontend-box/src/app/playtime.service.ts b/src/frontend-box/src/app/playtime.service.ts
index a8a92a0e..62495164 100644
--- a/src/frontend-box/src/app/playtime.service.ts
+++ b/src/frontend-box/src/app/playtime.service.ts
@@ -5,7 +5,9 @@ import { catchError, of, switchMap, timer } from 'rxjs'
import { environment } from 'src/environments/environment'
import type { PlaytimeStatus } from './playtime.model'
-const POLL_INTERVAL_MS = 30_000
+// Polled by chip + overlay + player page. Endpoint just reads /tmp/playtime.json
+// (tmpfs), so 5s is fine and gives snappy state transitions in the UI.
+const POLL_INTERVAL_MS = 5_000
@Injectable({ providedIn: 'root' })
export class PlaytimeService {
From 37c29eb492bcd008cc7558249734b4ed71a5b866 Mon Sep 17 00:00:00 2001
From: Waldemar Berg
Date: Wed, 6 May 2026 20:27:35 +0200
Subject: [PATCH 08/20] playtime: position chip below toolbar so it doesn't
overlap status icons
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Top-right was occupied on the home page (cloud/battery icons) and on
the player page (volume + track counter). Drop the chip 64px to sit
just under the typical ion-toolbar — gives it a clear lane on every
page without colliding with toolbar contents.
---
.../src/app/playtime-chip/playtime-chip.component.scss | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/src/frontend-box/src/app/playtime-chip/playtime-chip.component.scss b/src/frontend-box/src/app/playtime-chip/playtime-chip.component.scss
index 98f9ddb4..8f7c94b5 100644
--- a/src/frontend-box/src/app/playtime-chip/playtime-chip.component.scss
+++ b/src/frontend-box/src/app/playtime-chip/playtime-chip.component.scss
@@ -1,6 +1,9 @@
.playtime-chip {
position: fixed;
- top: 6px;
+ // Sit just below the typical ion-toolbar (56px in md mode) so the chip
+ // never overlaps the cloud/battery icons (home), the volume + track
+ // counter (player), or any other toolbar elements on other pages.
+ top: 64px;
right: 8px;
z-index: 9000;
display: inline-flex;
From a076d4c57a499fbe8adbc55307632acbfac90cc9 Mon Sep 17 00:00:00 2001
From: Waldemar Berg
Date: Wed, 6 May 2026 21:03:02 +0200
Subject: [PATCH 09/20] playtime: surface key state transitions in default log
+ replace emoji
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The player's logger filters by config.server.logLevel, default 'error'.
That swallowed every log.info() call I added — making it impossible
to see when grace started/ended without first toggling Controller
Debugging in the admin UI. Switch the limit-reached / finalize /
day-rollover / startup-resume / rejected-command messages to
console.log/console.error so they always appear regardless of level.
log.debug() messages stay where they are (still gated, as intended
for verbose tracing).
Also fix the music-note emoji in the blocked overlay rendering as a
hollow rectangle on the kiosk Chromium (no color-emoji font on the
box). Replace the inline 🎶 with two ion-icon "musical-notes-outline"
glyphs flanking the message — same icon family that already works
elsewhere, so guaranteed to render.
---
src/backend-player/src/spotify-control.js | 16 +++++++++-------
.../playtime-blocked-overlay.component.html | 6 +++++-
.../playtime-blocked-overlay.component.scss | 9 +++++++++
.../playtime-blocked-overlay.component.ts | 4 ++--
4 files changed, 25 insertions(+), 10 deletions(-)
diff --git a/src/backend-player/src/spotify-control.js b/src/backend-player/src/spotify-control.js
index 87f53938..2f19df37 100644
--- a/src/backend-player/src/spotify-control.js
+++ b/src/backend-player/src/spotify-control.js
@@ -258,12 +258,13 @@ function loadPlaytimeCheckpoint() {
playtimeState.date = data.date
playtimeState.dayKey = data.dayKey || today.dayKey
playtimeState.usedSeconds = Number(data.usedSeconds) || 0
- log.info(
+ // console.log so it shows even when logLevel='error' (the default)
+ console.log(
`${new Date().toLocaleString()}: [Playtime] Resumed counter: ${playtimeState.usedSeconds}s for ${playtimeState.date}`,
)
}
} catch (e) {
- log.error(`${new Date().toLocaleString()}: [Playtime] Failed to load checkpoint:`, e)
+ console.error(`${new Date().toLocaleString()}: [Playtime] Failed to load checkpoint:`, e)
}
}
@@ -312,13 +313,14 @@ function isPlaytimeBlocked() {
// Transition to fully-stopped state. Called from the tick on grace timeout, from the
// mplayer track-change/playlist-finish handlers, or directly when grace=0.
function finalizePlaytimeBlock(reason) {
- log.info(`${new Date().toLocaleString()}: [Playtime] Finalizing block (${reason})`)
+ // console.log so it shows even when logLevel='error' (the default)
+ console.log(`${new Date().toLocaleString()}: [Playtime] Finalizing block (${reason})`)
playtimeState.state = 'blocked'
playtimeState.graceEndsAt = null
try {
stop()
} catch (e) {
- log.error(`${new Date().toLocaleString()}: [Playtime] Error stopping playback:`, e)
+ console.error(`${new Date().toLocaleString()}: [Playtime] Error stopping playback:`, e)
}
writePlaytimeCheckpoint()
}
@@ -361,7 +363,7 @@ function playtimeTick() {
playtimeState.state = 'normal'
playtimeState.graceEndsAt = null
writePlaytimeCheckpoint()
- log.info(`${now.toLocaleString()}: [Playtime] New day: ${today.dateStr} (${today.dayKey})`)
+ console.log(`${now.toLocaleString()}: [Playtime] New day: ${today.dateStr} (${today.dayKey})`)
}
// Increment counter only when actually playing
if (isActuallyPlaying()) {
@@ -378,7 +380,7 @@ function playtimeTick() {
if (overrunMs > 0) {
playtimeState.state = 'grace'
playtimeState.graceEndsAt = Date.now() + overrunMs
- log.info(
+ console.log(
`${now.toLocaleString()}: [Playtime] Daily limit reached (${limit} min for ${today.dayKey}). Entering grace period (max ${cfg.maxOverrunMinutes} min until current track ends).`,
)
writePlaytimeCheckpoint()
@@ -1259,7 +1261,7 @@ app.use((req, res) => {
// Playtime limit: refuse new playback when the daily cap is reached.
// Pause/stop/volume/system commands fall through normally.
if (isPlaytimeBlocked() && isPlayInitiatingCommand(command)) {
- log.info(
+ console.log(
`${new Date().toLocaleString()}: [Playtime] Rejected command (limit reached): name=${command.name} dir=${command.dir}`,
)
res.status(423).send({ status: 'blocked', error: 'playtime_limit_reached' })
diff --git a/src/frontend-box/src/app/playtime-blocked-overlay/playtime-blocked-overlay.component.html b/src/frontend-box/src/app/playtime-blocked-overlay/playtime-blocked-overlay.component.html
index 23a5ad85..09656bc8 100644
--- a/src/frontend-box/src/app/playtime-blocked-overlay/playtime-blocked-overlay.component.html
+++ b/src/frontend-box/src/app/playtime-blocked-overlay/playtime-blocked-overlay.component.html
@@ -2,6 +2,10 @@
Heute war genug Musik
-
Morgen geht's weiter 🎶
+
+
+ Morgen geht's weiter
+
+
diff --git a/src/frontend-box/src/app/playtime-blocked-overlay/playtime-blocked-overlay.component.scss b/src/frontend-box/src/app/playtime-blocked-overlay/playtime-blocked-overlay.component.scss
index 184ee490..a9094cdf 100644
--- a/src/frontend-box/src/app/playtime-blocked-overlay/playtime-blocked-overlay.component.scss
+++ b/src/frontend-box/src/app/playtime-blocked-overlay/playtime-blocked-overlay.component.scss
@@ -37,5 +37,14 @@
opacity: 0.8;
margin: 0;
line-height: 1.3;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.4rem;
+ }
+
+ .inline-icon {
+ font-size: 1.4rem;
+ opacity: 0.85;
+ vertical-align: middle;
}
}
diff --git a/src/frontend-box/src/app/playtime-blocked-overlay/playtime-blocked-overlay.component.ts b/src/frontend-box/src/app/playtime-blocked-overlay/playtime-blocked-overlay.component.ts
index cae46a16..d9e23ffb 100644
--- a/src/frontend-box/src/app/playtime-blocked-overlay/playtime-blocked-overlay.component.ts
+++ b/src/frontend-box/src/app/playtime-blocked-overlay/playtime-blocked-overlay.component.ts
@@ -1,7 +1,7 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'
import { IonIcon } from '@ionic/angular/standalone'
import { addIcons } from 'ionicons'
-import { moonOutline } from 'ionicons/icons'
+import { moonOutline, musicalNotesOutline } from 'ionicons/icons'
@Component({
selector: 'mupi-playtime-blocked',
@@ -12,6 +12,6 @@ import { moonOutline } from 'ionicons/icons'
})
export class PlaytimeBlockedOverlayComponent {
constructor() {
- addIcons({ moonOutline })
+ addIcons({ moonOutline, musicalNotesOutline })
}
}
From ce40626be97aa2a20a7b57bb40e9f4c12eb5f47e Mon Sep 17 00:00:00 2001
From: Waldemar Berg
Date: Thu, 7 May 2026 10:01:39 +0200
Subject: [PATCH 10/20] frontend-box: persist resume on cap from anywhere, not
just player page
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
saveResumeFiles() lived only in player.page.ts, so when playtime or
quiet-hours stopped playback while the user was on the home page (player
page unmounted), no resume entry was written and "weiterhören" silently
did nothing.
Lift the trigger to AppComponent (root, alive for the kiosk's lifetime):
- CurrentMediaService holds the most recently played Media, set by
PlayerService.{playMedia,resumeMedia}.
- buildResumeMedia() centralises the spotify/library/rss field mapping
that was inline in player.page so both savers produce identical entries.
- An effect in AppComponent watches PlaytimeService.status() and writes a
resume entry on the normal -> grace/blocked transition.
Player.page's in-page saver is left intact as a second safety net (30s
cadence, on-leave save, last-minute boost). Backend's composite-key dedup
makes overlapping writes safe.
---
src/frontend-box/src/app/app.component.ts | 53 ++++++++++++++++++-
.../src/app/current-media.service.ts | 25 +++++++++
src/frontend-box/src/app/player.service.ts | 7 +++
src/frontend-box/src/app/resume-builder.ts | 36 +++++++++++++
4 files changed, 120 insertions(+), 1 deletion(-)
create mode 100644 src/frontend-box/src/app/current-media.service.ts
create mode 100644 src/frontend-box/src/app/resume-builder.ts
diff --git a/src/frontend-box/src/app/app.component.ts b/src/frontend-box/src/app/app.component.ts
index afb907b2..9a9ce9e5 100644
--- a/src/frontend-box/src/app/app.component.ts
+++ b/src/frontend-box/src/app/app.component.ts
@@ -1,15 +1,21 @@
import { HttpClient } from '@angular/common/http'
-import { ChangeDetectionStrategy, Component, computed, Signal } from '@angular/core'
+import { ChangeDetectionStrategy, Component, computed, effect, Signal } from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import { IonApp, IonRouterOutlet } from '@ionic/angular/standalone'
import { distinctUntilChanged, interval, map, Observable, switchMap } from 'rxjs'
import { environment } from 'src/environments/environment'
+import { CurrentMediaService } from './current-media.service'
+import type { CurrentMPlayer } from './current.mplayer'
+import type { CurrentSpotify } from './current.spotify'
import { DisplayManagerService } from './display-manager.service'
import { ExternalPlaybackNavigatorService } from './external-playback-navigator.service'
+import { MediaService } from './media.service'
import { Monitor } from './monitor'
+import type { PlaytimePlayState } from './playtime.model'
import { PlaytimeService } from './playtime.service'
import { PlaytimeBlockedOverlayComponent } from './playtime-blocked-overlay/playtime-blocked-overlay.component'
import { PlaytimeChipComponent } from './playtime-chip/playtime-chip.component'
+import { buildResumeMedia } from './resume-builder'
@Component({
selector: 'app-root',
@@ -22,11 +28,22 @@ export class AppComponent {
protected monitorOff: Signal
protected playtimeBlocked: Signal
+ // Latest state snapshots — kept fresh by ngOnInit subscriptions on
+ // mediaService.current$/local$ so the resume-on-cap effect can read them
+ // synchronously when a transition fires.
+ private latestSpotify: CurrentSpotify | null = null
+ private latestLocal: CurrentMPlayer | null = null
+ // Track previous playtime state to detect normal -> grace/blocked transitions.
+ // 'unknown' on first tick avoids spurious save before we know the baseline.
+ private prevPlaytimeState: PlaytimePlayState | 'unknown' = 'unknown'
+
public constructor(
private http: HttpClient,
_externalPlaybackNavigator: ExternalPlaybackNavigatorService,
_displayManager: DisplayManagerService,
playtimeService: PlaytimeService,
+ private mediaService: MediaService,
+ private currentMediaService: CurrentMediaService,
) {
this.monitorOff = toSignal(
// 1.5s should be enough to be somewhat "recent".
@@ -41,5 +58,39 @@ export class AppComponent {
const s = playtimeService.status()
return s.enabled === true && s.state === 'blocked'
})
+
+ // Keep player-state snapshots fresh. AppComponent is the root component
+ // and lives for the kiosk's lifetime, so these subscriptions never need
+ // teardown; they also cause MediaService to keep its shared polling alive.
+ this.mediaService.current$.subscribe((s) => {
+ this.latestSpotify = s
+ })
+ this.mediaService.local$.subscribe((l) => {
+ this.latestLocal = l
+ })
+
+ // Global resume-on-cap: when playtime/quiet hours transitions
+ // normal -> grace or normal -> blocked, persist a resume entry for the
+ // currently-playing Media. This is what makes "weiterhören wo aufgehört"
+ // work even if the user listens from the home page (player page unmounted,
+ // its in-page saver inert). Backend's composite-key dedup means the entry
+ // overwrites any existing resume for the same item.
+ effect(() => {
+ const status = playtimeService.status()
+ if (!status.enabled) {
+ this.prevPlaytimeState = 'unknown'
+ return
+ }
+ const cur = status.state
+ const prev = this.prevPlaytimeState
+ this.prevPlaytimeState = cur
+ if (prev === 'unknown' || prev === cur) return
+ if (cur !== 'grace' && cur !== 'blocked') return
+
+ const source = this.currentMediaService.get()
+ if (!source) return
+ const resumeMedia = buildResumeMedia(source, this.latestSpotify, this.latestLocal)
+ this.mediaService.addRawResume(resumeMedia)
+ })
}
}
diff --git a/src/frontend-box/src/app/current-media.service.ts b/src/frontend-box/src/app/current-media.service.ts
new file mode 100644
index 00000000..9d347ac5
--- /dev/null
+++ b/src/frontend-box/src/app/current-media.service.ts
@@ -0,0 +1,25 @@
+import { Injectable } from '@angular/core'
+import { BehaviorSubject } from 'rxjs'
+import type { Media } from './media'
+
+// Tracks the Media that the player most recently started playing. Set by
+// PlayerService.playMedia / resumeMedia. Read by the global resume-on-cap
+// effect in AppComponent so we can write a resume entry when playtime / quiet
+// hours stops playback while the user is on the home screen (and the player
+// page — which historically owned saveResumeFiles — is unmounted).
+@Injectable({ providedIn: 'root' })
+export class CurrentMediaService {
+ readonly currentMedia$ = new BehaviorSubject(null)
+
+ set(media: Media | null): void {
+ this.currentMedia$.next(media ? { ...media } : null)
+ }
+
+ get(): Media | null {
+ return this.currentMedia$.value
+ }
+
+ clear(): void {
+ this.currentMedia$.next(null)
+ }
+}
diff --git a/src/frontend-box/src/app/player.service.ts b/src/frontend-box/src/app/player.service.ts
index 42059219..0c4fa887 100644
--- a/src/frontend-box/src/app/player.service.ts
+++ b/src/frontend-box/src/app/player.service.ts
@@ -4,6 +4,7 @@ import type { ServerHttpApiConfig } from '@backend-api/server.model'
import type { Observable } from 'rxjs'
import { publishReplay, refCount } from 'rxjs/operators'
import { environment } from '../environments/environment'
+import { CurrentMediaService } from './current-media.service'
import { LogService } from './log.service'
import type { Media } from './media'
import { SpotifyService } from './spotify.service'
@@ -42,6 +43,7 @@ export class PlayerService {
private http: HttpClient,
private logService: LogService,
private spotifyService: SpotifyService,
+ private currentMediaService: CurrentMediaService,
) {}
getConfig() {
@@ -124,6 +126,10 @@ export class PlayerService {
}
}
+ // Snapshot the Media so the global resume-on-cap effect (AppComponent)
+ // knows what was playing if playtime/quiet stops it while the user is
+ // off the player page.
+ this.currentMediaService.set(media)
this.sendRequest(url)
return true
}
@@ -148,6 +154,7 @@ export class PlayerService {
url = `spotify/now/spotify:show:${encodeURIComponent(media.audiobookid)}:${media.resumespotifytrack_number}:${media.resumespotifyprogress_ms}`
}
+ this.currentMediaService.set(media)
this.sendRequest(url)
return true
}
diff --git a/src/frontend-box/src/app/resume-builder.ts b/src/frontend-box/src/app/resume-builder.ts
new file mode 100644
index 00000000..e5952189
--- /dev/null
+++ b/src/frontend-box/src/app/resume-builder.ts
@@ -0,0 +1,36 @@
+import type { CurrentMPlayer } from './current.mplayer'
+import type { CurrentSpotify } from './current.spotify'
+import type { Media } from './media'
+
+// Builds a resume-shaped Media from the Media that started playback plus the
+// most recent player state. Mirrors the field-mapping that lived inline in
+// player.page.ts:saveResumeFiles so both the in-page saver and the global
+// resume-on-cap effect produce identical entries (which lets the backend's
+// composite-key dedup overwrite cleanly instead of duplicating).
+export function buildResumeMedia(
+ source: Media,
+ spotify: CurrentSpotify | null | undefined,
+ local: CurrentMPlayer | null | undefined,
+): Media {
+ const resume: Media = { ...source }
+
+ if (resume.type === 'spotify' && resume.showid) {
+ resume.resumespotifytrack_number = spotify?.item?.track_number || 1
+ resume.resumespotifyprogress_ms = spotify?.progress_ms || 0
+ resume.resumespotifyduration_ms = spotify?.item?.duration_ms || 0
+ } else if (resume.type === 'spotify') {
+ resume.resumespotifytrack_number = spotify?.item?.track_number || 0
+ resume.resumespotifyprogress_ms = spotify?.progress_ms || 0
+ resume.resumespotifyduration_ms = spotify?.item?.duration_ms || 0
+ } else if (resume.type === 'library') {
+ resume.resumelocalalbum = resume.category
+ resume.resumelocalcurrentTracknr = local?.currentTracknr || 0
+ resume.resumelocalprogressTime = local?.progressTime || 0
+ } else if (resume.type === 'rss') {
+ resume.resumerssprogressTime = local?.progressTime || 0
+ }
+
+ resume.category = 'resume'
+ resume.index = undefined
+ return resume
+}
From ab8204150009c6a6c4e6c025f8d91ccdca1cc7d1 Mon Sep 17 00:00:00 2001
From: Waldemar Berg
Date: Thu, 7 May 2026 10:08:20 +0200
Subject: [PATCH 11/20] frontend-box: stop leaking subscriptions in player.page
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
updateProgress() and saveResumeFiles() each re-subscribed to
mediaService.current$/local$ on every call without ever unsubscribing,
so a 60-min listen accrued ~120 dangling subscriptions. The values were
already being kept fresh by ngOnInit subscriptions — drop the redundant
re-subscribes and bind the ngOnInit ones to the component's lifetime via
takeUntilDestroyed(DestroyRef) so they tear down cleanly on navigation.
Also collapsed the two ngOnInit current$ subscriptions into one (they
both wrote to fields read elsewhere; no need for a second pass).
---
.../src/app/player/player.page.ts | 39 ++++++++-----------
1 file changed, 16 insertions(+), 23 deletions(-)
diff --git a/src/frontend-box/src/app/player/player.page.ts b/src/frontend-box/src/app/player/player.page.ts
index 73738aee..9d6364fa 100644
--- a/src/frontend-box/src/app/player/player.page.ts
+++ b/src/frontend-box/src/app/player/player.page.ts
@@ -1,5 +1,6 @@
import { AsyncPipe } from '@angular/common'
-import { Component, OnInit, ViewChild } from '@angular/core'
+import { Component, DestroyRef, inject, OnInit, ViewChild } from '@angular/core'
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop'
import { FormsModule } from '@angular/forms'
import { ActivatedRoute, Router } from '@angular/router'
import {
@@ -91,6 +92,7 @@ export class PlayerPage implements OnInit {
// Tracks the playtime state across ticks so we can detect transitions
// (normal -> grace, grace -> blocked, etc.) and persist resume on time.
private prevPlaytimeState: PlaytimePlayState | 'unknown' = 'unknown'
+ private destroyRef = inject(DestroyRef)
public readonly spotify$: Observable
public readonly local$: Observable
@@ -136,14 +138,12 @@ export class PlayerPage implements OnInit {
this.handleExternalPlayback()
}
- this.mediaService.current$.subscribe((spotify) => {
+ // Track player state for the lifetime of this component. takeUntilDestroyed
+ // ties the subscription to the page; previously updateProgress() and
+ // saveResumeFiles() each re-subscribed on every call without ever
+ // unsubscribing, so a 60-min listen accrued ~120 lingering subscriptions.
+ this.mediaService.current$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((spotify) => {
this.currentPlayedSpotify = spotify
- })
- this.mediaService.local$.subscribe((local) => {
- this.currentPlayedLocal = local
- })
- // Use cover from CurrentSpotify for Spotify content, fallback to media.cover for other types
- this.mediaService.current$.subscribe((spotify) => {
if (this.media?.type === 'spotify' && spotify?.item?.album?.images?.[0]?.url) {
this.cover = spotify.item.album.images[0].url
} else if (this.media?.cover) {
@@ -152,7 +152,10 @@ export class PlayerPage implements OnInit {
this.cover = '../assets/images/nocover_mupi.png'
}
})
- this.mediaService.albumStop$.subscribe((albumStop) => {
+ this.mediaService.local$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((local) => {
+ this.currentPlayedLocal = local
+ })
+ this.mediaService.albumStop$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((albumStop) => {
this.albumStop = albumStop
})
}
@@ -176,7 +179,7 @@ export class PlayerPage implements OnInit {
}
// Subscribe to currentTrack$ to update when track info becomes available
- this.spotifyService.currentTrack$.subscribe((track) => {
+ this.spotifyService.currentTrack$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((track) => {
if (track && this.media.title === 'External Playback') {
this.logService.log('[PlayerPage] Updating media object with track info:', track.name)
this.media = this.spotifyService.createMediaFromSpotifyTrack(track)
@@ -196,13 +199,9 @@ export class PlayerPage implements OnInit {
}
updateProgress() {
- this.mediaService.current$.subscribe((spotify) => {
- this.currentPlayedSpotify = spotify
- })
- this.mediaService.local$.subscribe((local) => {
- this.currentPlayedLocal = local
- })
-
+ // currentPlayedSpotify / currentPlayedLocal are kept fresh by the
+ // takeUntilDestroyed-bound subscriptions in ngOnInit — read them
+ // directly here instead of re-subscribing on every tick.
this.playing = !this.currentPlayedLocal?.pause
if (this.playing) {
this.resumeTimer++
@@ -381,12 +380,6 @@ export class PlayerPage implements OnInit {
saveResumeFiles() {
this.resumemedia = Object.assign({}, this.media)
- this.mediaService.current$.subscribe((spotify) => {
- this.currentPlayedSpotify = spotify
- })
- this.mediaService.local$.subscribe((local) => {
- this.currentPlayedLocal = local
- })
if (this.resumemedia.type === 'spotify' && this.resumemedia?.showid) {
this.resumemedia.resumespotifytrack_number = this.currentPlayedSpotify?.item?.track_number || 1
this.resumemedia.resumespotifyprogress_ms = this.currentPlayedSpotify?.progress_ms || 0
From 3a6e456bdf86d8f1740a2dddc0b975e7bb48d66b Mon Sep 17 00:00:00 2001
From: Waldemar Berg
Date: Thu, 7 May 2026 10:31:17 +0200
Subject: [PATCH 12/20] frontend-box: gate resume saves on actively-listened
seconds
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The historical 30s guard in player.page.ionViewWillLeave was the right
intent — don't pollute the resume swiper with "kid touched the wrong
cover" misclicks — but it was applied inconsistently (transition saves
and the new global cap saver in AppComponent didn't enforce it) and it
counted wall-clock time, so a paused player still ticked the threshold.
Lift the threshold into CurrentMediaService: an internal 1s ticker
increments only while playback is actually running (current$.is_playing
or local$.playing) and flips a worthResume flag at 30s. The flag resets
on every set() — i.e. every new playMedia/resumeMedia.
shouldPersistResume() is the single gate consulted by every save path:
- player.page.saveResumeFiles (early-return covers the 30s cadence,
the cap-transition save, and the on-leave save)
- AppComponent's global cap-transition effect
The redundant resumeTimer > 30 check in ionViewWillLeave is gone.
---
src/frontend-box/src/app/app.component.ts | 4 ++
.../src/app/current-media.service.ts | 63 ++++++++++++++++++-
.../src/app/player/player.page.ts | 12 +++-
3 files changed, 76 insertions(+), 3 deletions(-)
diff --git a/src/frontend-box/src/app/app.component.ts b/src/frontend-box/src/app/app.component.ts
index 9a9ce9e5..9555ad7c 100644
--- a/src/frontend-box/src/app/app.component.ts
+++ b/src/frontend-box/src/app/app.component.ts
@@ -75,6 +75,9 @@ export class AppComponent {
// work even if the user listens from the home page (player page unmounted,
// its in-page saver inert). Backend's composite-key dedup means the entry
// overwrites any existing resume for the same item.
+ //
+ // Gated on shouldPersistResume() so a wrong-cover-touch right before a
+ // cap doesn't leave a stale entry in the resume swiper.
effect(() => {
const status = playtimeService.status()
if (!status.enabled) {
@@ -86,6 +89,7 @@ export class AppComponent {
this.prevPlaytimeState = cur
if (prev === 'unknown' || prev === cur) return
if (cur !== 'grace' && cur !== 'blocked') return
+ if (!this.currentMediaService.shouldPersistResume()) return
const source = this.currentMediaService.get()
if (!source) return
diff --git a/src/frontend-box/src/app/current-media.service.ts b/src/frontend-box/src/app/current-media.service.ts
index 9d347ac5..9096cece 100644
--- a/src/frontend-box/src/app/current-media.service.ts
+++ b/src/frontend-box/src/app/current-media.service.ts
@@ -1,17 +1,52 @@
import { Injectable } from '@angular/core'
-import { BehaviorSubject } from 'rxjs'
+import { BehaviorSubject, interval } from 'rxjs'
+import type { CurrentMPlayer } from './current.mplayer'
+import type { CurrentSpotify } from './current.spotify'
import type { Media } from './media'
+import { MediaService } from './media.service'
// Tracks the Media that the player most recently started playing. Set by
// PlayerService.playMedia / resumeMedia. Read by the global resume-on-cap
// effect in AppComponent so we can write a resume entry when playtime / quiet
// hours stops playback while the user is on the home screen (and the player
// page — which historically owned saveResumeFiles — is unmounted).
+//
+// Also gates whether a resume entry should be persisted at all: a kid touching
+// the wrong cover for a few seconds shouldn't pollute the resume swiper.
+// shouldPersistResume() returns true once the kid has actively listened to
+// the current Media for RESUME_THRESHOLD_S seconds — wall-clock pauses don't
+// count, and the counter resets on every new playMedia/resumeMedia.
@Injectable({ providedIn: 'root' })
export class CurrentMediaService {
+ // Active-listening seconds required before a resume entry is worth keeping.
+ // 30s is the historical value from the player.page's onLeave guard; lifting
+ // it into one service so the same intent applies to the Cap-time saver in
+ // AppComponent and to any future save path.
+ private static readonly RESUME_THRESHOLD_S = 30
+
readonly currentMedia$ = new BehaviorSubject(null)
+ private activeSeconds = 0
+ private worthResume = false
+ private latestSpotify: CurrentSpotify | null = null
+ private latestLocal: CurrentMPlayer | null = null
+
+ constructor(mediaService: MediaService) {
+ // current$ / local$ are shareReplay'd inside MediaService — keeping a
+ // forever-subscription here is cheap and runs alongside the existing
+ // pollers (1s for mplayer, 1s/10s for Spotify depending on SDK use).
+ mediaService.current$.subscribe((s) => {
+ this.latestSpotify = s
+ })
+ mediaService.local$.subscribe((l) => {
+ this.latestLocal = l
+ })
+ interval(1000).subscribe(() => this.tick())
+ }
+
set(media: Media | null): void {
+ this.activeSeconds = 0
+ this.worthResume = false
this.currentMedia$.next(media ? { ...media } : null)
}
@@ -20,6 +55,30 @@ export class CurrentMediaService {
}
clear(): void {
- this.currentMedia$.next(null)
+ this.set(null)
+ }
+
+ // Single source of truth for "has this kid been listening long enough that
+ // we should persist a resume entry?" Used by every save path (player.page
+ // saveResumeFiles, AppComponent global cap saver) so the gating is
+ // consistent and based on actual listening, not wall-clock time.
+ shouldPersistResume(): boolean {
+ return this.worthResume
+ }
+
+ private tick(): void {
+ if (this.worthResume) return
+ if (!this.currentMedia$.value) return
+ if (!this.isActuallyPlaying()) return
+ this.activeSeconds++
+ if (this.activeSeconds >= CurrentMediaService.RESUME_THRESHOLD_S) {
+ this.worthResume = true
+ }
+ }
+
+ private isActuallyPlaying(): boolean {
+ if (this.latestLocal?.playing === true) return true
+ if (this.latestSpotify?.is_playing === true) return true
+ return false
}
}
diff --git a/src/frontend-box/src/app/player/player.page.ts b/src/frontend-box/src/app/player/player.page.ts
index 9d6364fa..c097129f 100644
--- a/src/frontend-box/src/app/player/player.page.ts
+++ b/src/frontend-box/src/app/player/player.page.ts
@@ -34,6 +34,7 @@ import {
} from 'ionicons/icons'
import type { Observable } from 'rxjs'
import type { AlbumStop } from '../albumstop'
+import { CurrentMediaService } from '../current-media.service'
import type { CurrentMPlayer } from '../current.mplayer'
import type { CurrentSpotify } from '../current.spotify'
import { LogService } from '../log.service'
@@ -105,6 +106,7 @@ export class PlayerPage implements OnInit {
private playerService: PlayerService,
private spotifyService: SpotifyService,
private playtimeService: PlaytimeService,
+ private currentMediaService: CurrentMediaService,
) {
this.spotify$ = this.mediaService.current$
this.local$ = this.mediaService.local$
@@ -288,9 +290,12 @@ export class PlayerPage implements OnInit {
if (
(this.media.type === 'spotify' || this.media.type === 'library' || this.media.type === 'rss') &&
!this.media.shuffle &&
- this.resumeTimer > 30 &&
this.playing
) {
+ // saveResumeFiles itself enforces the listening-time threshold via
+ // CurrentMediaService.shouldPersistResume(); the local resumeTimer > 30
+ // guard that used to live here is gone — it was page-mount-scoped and
+ // wall-clock-based, both of which the central service handles better.
this.saveResumeFiles()
}
this.updateProgression = false
@@ -379,6 +384,11 @@ export class PlayerPage implements OnInit {
}
saveResumeFiles() {
+ // Single gate for "is this listen worth persisting?" — covers the 30s
+ // updateProgress cadence, the on-leave save, and the cap-transition save.
+ // Resets on every new playMedia/resumeMedia, counts only active playback.
+ if (!this.currentMediaService.shouldPersistResume()) return
+
this.resumemedia = Object.assign({}, this.media)
if (this.resumemedia.type === 'spotify' && this.resumemedia?.showid) {
this.resumemedia.resumespotifytrack_number = this.currentPlayedSpotify?.item?.track_number || 1
From 67506ea56b2924830c9e91c32a31796b632c375b Mon Sep 17 00:00:00 2001
From: Waldemar Berg
Date: Wed, 6 May 2026 22:36:33 +0200
Subject: [PATCH 13/20] quiet hours: per-weekday playback windows (e.g.
homework / bedtime)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds a second restriction mechanism alongside the daily playtime cap.
While playtime is duration-based, quiet hours are time-window-based:
each weekday has its own list of windows ("from HH:MM to HH:MM",
optional label), and playback is blocked while "now" falls inside any
of them. Multiple windows per day are supported, so a typical schedule
might combine homework time, mealtime and bedtime.
Schema (mupiboxconfig.json):
"quietHours": {
"enabled": false,
"maxOverrunMinutes": 10,
"schedule": {
"mon": [
{ "from": "14:00", "to": "16:00", "label": "Hausaufgaben" },
{ "from": "20:00", "to": "06:00", "label": "Schlafenszeit" }
],
...
}
}
Windows belong to the day they start on; a from > to entry spans
midnight (e.g. 20:00 → 06:00 covers the night).
State machine: same 'normal | grace | blocked' as playtime, but
purely time-driven (no counter, no checkpoint). Grace lets the
current track finish when a window starts, with a configurable cap.
mplayer track-change / playlist-finish events finalize quiet grace
the same way they finalize playtime grace; for Spotify only the
maxOverrunMinutes timeout fires.
When a window ends, playback is *not* auto-started — the kid taps
play to resume. When the feature is disabled mid-window, the box is
released immediately (same idea: don't auto-start, just unblock).
API surface change: GET /api/playtime now returns nested sub-objects
playtime.* and quiet.*, plus combined top-level state and blockSource.
Frontend updated to read from the nested structure. Chip stays bound
to the playtime sub-status only (quiet has no countdown). Overlay
picks heading/icon/subheading based on blockSource and the active
window's label.
Admin UI: new "Quiet hours" section in mupi.php with a tiny JS-driven
multi-window editor per day (add/remove rows). Empty rows are dropped
on save. Save triggers pm2 restart of the player like the playtime
save does, since both are loaded once via require() at startup.
---
AdminInterface/www/mupi.php | 140 +++++++++++
config/templates/mupiboxconfig.json | 13 +
.../src/models/mupibox-config.model.ts | 3 +-
src/backend-api/src/models/playtime.model.ts | 29 ++-
src/backend-player/src/spotify-control.js | 230 +++++++++++++++---
.../src/app/player/player.page.ts | 17 +-
.../playtime-blocked-overlay.component.html | 6 +-
.../playtime-blocked-overlay.component.ts | 46 +++-
.../playtime-chip/playtime-chip.component.ts | 8 +-
src/frontend-box/src/app/playtime.model.ts | 24 +-
10 files changed, 466 insertions(+), 50 deletions(-)
diff --git a/AdminInterface/www/mupi.php b/AdminInterface/www/mupi.php
index aa1ac6e8..252e9ff5 100644
--- a/AdminInterface/www/mupi.php
+++ b/AdminInterface/www/mupi.php
@@ -471,6 +471,45 @@
$CHANGE_TXT = $CHANGE_TXT."
Define time windows per weekday in which playback is automatically blocked (e.g. homework time, mealtimes, bedtime). Multiple windows per day are supported. Settings take effect after saving (the player is restarted automatically).
+
Set a window's from later than its to to span midnight (e.g. 20:00 → 06:00 covers the night). The optional label is shown to the kid on the block screen.
When a quiet window starts, allow up to this many additional minutes for the current track to finish naturally. 0 = stop immediately at the window boundary. Default: 10. Maximum: 60.
+ min
+
+
+
Windows per weekday
+
Use „+ Add window" to add another row to a day. Empty rows are ignored on save.
diff --git a/config/templates/mupiboxconfig.json b/config/templates/mupiboxconfig.json
index b7991001..638a2d2b 100644
--- a/config/templates/mupiboxconfig.json
+++ b/config/templates/mupiboxconfig.json
@@ -279,6 +279,19 @@
"sun": 60
}
},
+ "quietHours": {
+ "enabled": false,
+ "maxOverrunMinutes": 10,
+ "schedule": {
+ "mon": [],
+ "tue": [],
+ "wed": [],
+ "thu": [],
+ "fri": [],
+ "sat": [],
+ "sun": []
+ }
+ },
"shim": {
"poweroffPin": "4",
"triggerPin": "17",
diff --git a/src/backend-api/src/models/mupibox-config.model.ts b/src/backend-api/src/models/mupibox-config.model.ts
index 1abfe877..8a74265b 100644
--- a/src/backend-api/src/models/mupibox-config.model.ts
+++ b/src/backend-api/src/models/mupibox-config.model.ts
@@ -1,4 +1,4 @@
-import type { PlaytimeLimitConfig } from './playtime.model'
+import type { PlaytimeLimitConfig, QuietHoursConfig } from './playtime.model'
export interface MupiboxConfig {
spotify?: {
@@ -6,5 +6,6 @@ export interface MupiboxConfig {
[key: string]: unknown
}
playtimeLimit?: PlaytimeLimitConfig
+ quietHours?: QuietHoursConfig
[key: string]: unknown
}
diff --git a/src/backend-api/src/models/playtime.model.ts b/src/backend-api/src/models/playtime.model.ts
index 2bf775ec..2eff0d8e 100644
--- a/src/backend-api/src/models/playtime.model.ts
+++ b/src/backend-api/src/models/playtime.model.ts
@@ -9,11 +9,33 @@ export interface PlaytimeLimitConfig {
limitsMinutes?: PlaytimeLimitsMinutes
}
-// 'normal' → under daily limit, playback unrestricted
-// 'grace' → over limit, current track allowed to finish; new commands blocked
+// === Quiet Hours ===
+
+export interface QuietHoursWindow {
+ from: string // HH:MM
+ to: string // HH:MM, may be < from to span midnight
+ label?: string
+}
+
+export type QuietHoursSchedule = Partial>
+
+export interface QuietHoursConfig {
+ enabled: boolean
+ maxOverrunMinutes?: number
+ schedule?: QuietHoursSchedule
+}
+
+// === Combined playback status ===
+
+// 'normal' → no restriction, playback unrestricted
+// 'grace' → over a limit (playtime or quiet entry), current track allowed to finish
// 'blocked' → fully stopped, frontend overlays the screen
export type PlaytimePlayState = 'normal' | 'grace' | 'blocked'
+// Identifies *what* is currently restricting playback (when state !== 'normal').
+// 'playtime' = daily-limit-based, 'quiet' = time-window-based.
+export type PlaybackBlockSource = 'playtime' | 'quiet'
+
export type PlaytimeStatus = PlaytimeStatusEnabled | PlaytimeStatusDisabled
export interface PlaytimeStatusDisabled {
@@ -23,6 +45,9 @@ export interface PlaytimeStatusDisabled {
export interface PlaytimeStatusEnabled {
enabled: true
state: PlaytimePlayState
+ blockSource: PlaybackBlockSource | null
+ // Set when blockSource === 'quiet' and the active window has a label.
+ quietLabel?: string
date: string
dayKey: PlaytimeDayKey
limitMinutes: number
diff --git a/src/backend-player/src/spotify-control.js b/src/backend-player/src/spotify-control.js
index 2f19df37..26683795 100644
--- a/src/backend-player/src/spotify-control.js
+++ b/src/backend-player/src/spotify-control.js
@@ -122,18 +122,26 @@ player.on('track-change', () => {
cmdCall('/usr/bin/python3 /usr/local/bin/mupibox/telegram_Track_Local.py')
})
-// Playtime grace period: stop at the next natural mplayer break point
+// Playtime + Quiet-Hours grace: stop at the next natural mplayer break point
// (start of next track, or end of playlist). Spotify doesn't surface these
-// events, so for Spotify the grace timeout in playtimeTick() is the only stop trigger.
+// events, so for Spotify the grace timeout in the tick is the only stop trigger.
+// Both sub-systems are checked independently — a single track-change can
+// finalize either or both if both happen to be in grace simultaneously.
player.on('track-change', () => {
if (playtimeState.state === 'grace') {
finalizePlaytimeBlock('next track would start during grace period')
}
+ if (quietHoursState.state === 'grace') {
+ finalizeQuietHoursBlock('next track would start during grace period')
+ }
})
player.on('playlist-finish', () => {
if (playtimeState.state === 'grace') {
finalizePlaytimeBlock('playlist finished during grace period')
}
+ if (quietHoursState.state === 'grace') {
+ finalizeQuietHoursBlock('playlist finished during grace period')
+ }
})
setInterval(() => {
@@ -222,6 +230,64 @@ function readPlaytimeConfig() {
}
}
+// === Quiet Hours ===
+const QUIET_DEFAULT_SCHEDULE = { mon: [], tue: [], wed: [], thu: [], fri: [], sat: [], sun: [] }
+
+function readQuietHoursConfig() {
+ const raw = muPiBoxConfig?.quietHours || {}
+ const maxOverrunMinutes =
+ Number.isInteger(raw.maxOverrunMinutes) && raw.maxOverrunMinutes >= 0 && raw.maxOverrunMinutes <= 60
+ ? raw.maxOverrunMinutes
+ : 10
+ return {
+ enabled: raw.enabled === true,
+ maxOverrunMinutes,
+ schedule: { ...QUIET_DEFAULT_SCHEDULE, ...(raw.schedule || {}) },
+ }
+}
+
+// 'HH:MM' → minutes since midnight, or null if invalid.
+function parseHHMMToMinutes(hhmm) {
+ if (typeof hhmm !== 'string') return null
+ const m = hhmm.match(/^(\d{1,2}):(\d{2})$/)
+ if (!m) return null
+ const h = Number(m[1])
+ const min = Number(m[2])
+ if (h < 0 || h > 23 || min < 0 || min > 59) return null
+ return h * 60 + min
+}
+
+// Returns the active quiet window object {from, to, label?} that contains "now",
+// or null if no window applies. Windows belong to the day they start on; a
+// midnight-spanning window (from > to) covers from `from` of its day until `to`
+// of the next day.
+function findActiveQuietWindow(now, schedule) {
+ const todayKey = PLAYTIME_DAY_KEYS[now.getDay()]
+ const yesterdayKey = PLAYTIME_DAY_KEYS[(now.getDay() + 6) % 7]
+ const nowMinutes = now.getHours() * 60 + now.getMinutes()
+
+ for (const w of schedule[todayKey] || []) {
+ const fromMin = parseHHMMToMinutes(w.from)
+ const toMin = parseHHMMToMinutes(w.to)
+ if (fromMin === null || toMin === null) continue
+ if (fromMin < toMin) {
+ if (nowMinutes >= fromMin && nowMinutes < toMin) return w
+ } else if (fromMin > toMin) {
+ // Same-day half of a midnight-spanning window
+ if (nowMinutes >= fromMin) return w
+ }
+ // fromMin === toMin: zero-length, skip
+ }
+ // Yesterday's wrapping windows (the to-half lands in today's early hours)
+ for (const w of schedule[yesterdayKey] || []) {
+ const fromMin = parseHHMMToMinutes(w.from)
+ const toMin = parseHHMMToMinutes(w.to)
+ if (fromMin === null || toMin === null) continue
+ if (fromMin > toMin && nowMinutes < toMin) return w
+ }
+ return null
+}
+
// Reset-hour shifts when "today" begins. With resetHour=4, Sunday 02:00 still counts as Saturday.
function getLogicalDay(now, resetHour) {
const shifted = new Date(now.getTime() - resetHour * 3600 * 1000)
@@ -249,6 +315,13 @@ const playtimeState = {
let playtimeLastCheckpointAt = 0
let playtimeLastCheckpointSeconds = -1
+// Quiet hours uses the same state machine but is purely time-window-driven (no counter).
+const quietHoursState = {
+ state: 'normal',
+ graceEndsAt: null,
+ activeWindow: null, // current window object {from, to, label?} when in_window
+}
+
function loadPlaytimeCheckpoint() {
try {
if (!fs.existsSync(PLAYTIME_CHECKPOINT_PATH)) return
@@ -268,25 +341,53 @@ function loadPlaytimeCheckpoint() {
}
}
-function writePlaytimeWorking() {
- const cfg = readPlaytimeConfig()
- if (!cfg.enabled) {
+function writeCombinedWorking() {
+ const ptCfg = readPlaytimeConfig()
+ const qhCfg = readQuietHoursConfig()
+
+ if (!ptCfg.enabled && !qhCfg.enabled) {
fs.writeFile(PLAYTIME_WORKING_PATH, JSON.stringify({ enabled: false }), () => {})
return
}
- const limit = cfg.limitsMinutes[playtimeState.dayKey] ?? 60
- const graceEndsInSeconds =
+
+ // Combined effective state across both sub-systems
+ let state = 'normal'
+ if (playtimeState.state === 'blocked' || quietHoursState.state === 'blocked') state = 'blocked'
+ else if (playtimeState.state === 'grace' || quietHoursState.state === 'grace') state = 'grace'
+
+ // blockSource: prefer 'quiet' over 'playtime' if both are restricting (more "explainable" to the kid)
+ let blockSource = null
+ if (quietHoursState.state !== 'normal') blockSource = 'quiet'
+ else if (playtimeState.state !== 'normal') blockSource = 'playtime'
+
+ const ptLimit = ptCfg.enabled ? (ptCfg.limitsMinutes[playtimeState.dayKey] ?? 60) : 0
+ const ptGraceEndsInSeconds =
playtimeState.graceEndsAt !== null ? Math.max(0, Math.ceil((playtimeState.graceEndsAt - Date.now()) / 1000)) : 0
+ const qhGraceEndsInSeconds =
+ quietHoursState.graceEndsAt !== null ? Math.max(0, Math.ceil((quietHoursState.graceEndsAt - Date.now()) / 1000)) : 0
+
const payload = {
enabled: true,
- state: playtimeState.state,
- date: playtimeState.date,
- dayKey: playtimeState.dayKey,
- limitMinutes: limit,
- usedSeconds: playtimeState.usedSeconds,
- remainingSeconds: Math.max(0, limit * 60 - playtimeState.usedSeconds),
- graceEndsInSeconds,
- resetHour: cfg.resetHour,
+ state,
+ blockSource,
+ playtime: {
+ enabled: ptCfg.enabled,
+ state: playtimeState.state,
+ date: playtimeState.date,
+ dayKey: playtimeState.dayKey,
+ limitMinutes: ptLimit,
+ usedSeconds: playtimeState.usedSeconds,
+ remainingSeconds: ptCfg.enabled ? Math.max(0, ptLimit * 60 - playtimeState.usedSeconds) : 0,
+ graceEndsInSeconds: ptGraceEndsInSeconds,
+ resetHour: ptCfg.resetHour,
+ },
+ quiet: {
+ enabled: qhCfg.enabled,
+ state: quietHoursState.state,
+ inWindow: quietHoursState.activeWindow !== null,
+ ...(quietHoursState.activeWindow?.label ? { label: quietHoursState.activeWindow.label } : {}),
+ graceEndsInSeconds: qhGraceEndsInSeconds,
+ },
}
fs.writeFile(PLAYTIME_WORKING_PATH, JSON.stringify(payload), () => {})
}
@@ -305,10 +406,17 @@ function writePlaytimeCheckpoint() {
}
// Used by the catch-all to decide whether to refuse new play/resume/skip commands.
-// During grace, the *current* track is allowed to finish but no new playback may start.
-function isPlaytimeBlocked() {
- return playtimeState.state === 'grace' || playtimeState.state === 'blocked'
+// True if EITHER playtime OR quiet hours is currently restricting playback.
+function isPlaybackBlocked() {
+ return (
+ playtimeState.state === 'grace' ||
+ playtimeState.state === 'blocked' ||
+ quietHoursState.state === 'grace' ||
+ quietHoursState.state === 'blocked'
+ )
}
+// Backwards-compat alias kept so older call sites keep working.
+const isPlaytimeBlocked = isPlaybackBlocked
// Transition to fully-stopped state. Called from the tick on grace timeout, from the
// mplayer track-change/playlist-finish handlers, or directly when grace=0.
@@ -325,6 +433,17 @@ function finalizePlaytimeBlock(reason) {
writePlaytimeCheckpoint()
}
+function finalizeQuietHoursBlock(reason) {
+ console.log(`${new Date().toLocaleString()}: [QuietHours] Finalizing block (${reason})`)
+ quietHoursState.state = 'blocked'
+ quietHoursState.graceEndsAt = null
+ try {
+ stop()
+ } catch (e) {
+ console.error(`${new Date().toLocaleString()}: [QuietHours] Error stopping playback:`, e)
+ }
+}
+
// Commands that *start or resume* playback. These get blocked when the daily cap is hit.
// Pause/stop/volume/system commands are NOT blocked — those should always work.
function isPlayInitiatingCommand(command) {
@@ -343,14 +462,16 @@ function isPlayInitiatingCommand(command) {
return false
}
-function playtimeTick() {
+// Updates playtimeState only (counter, day rollover, state transitions, SD checkpoint).
+// Working-state file is written separately by writeCombinedWorking once both
+// sub-systems have ticked, so the payload is consistent.
+function playtimeTickStep() {
const cfg = readPlaytimeConfig()
if (!cfg.enabled) {
if (playtimeState.state !== 'normal' || playtimeState.graceEndsAt !== null) {
playtimeState.state = 'normal'
playtimeState.graceEndsAt = null
}
- writePlaytimeWorking()
return
}
const now = new Date()
@@ -369,13 +490,11 @@ function playtimeTick() {
if (isActuallyPlaying()) {
playtimeState.usedSeconds++
}
- // State transitions
const limit = cfg.limitsMinutes[today.dayKey] ?? 60
const limitSeconds = limit * 60
const limitReached = playtimeState.usedSeconds >= limitSeconds
if (limitReached) {
if (playtimeState.state === 'normal') {
- // Just-reached transition
const overrunMs = cfg.maxOverrunMinutes * 60 * 1000
if (overrunMs > 0) {
playtimeState.state = 'grace'
@@ -394,9 +513,6 @@ function playtimeTick() {
}
// else 'blocked': stay blocked
}
- // Working state: every tick (tmpfs, no SD wear)
- writePlaytimeWorking()
- // SD checkpoint: every 60s if value changed
if (
Date.now() - playtimeLastCheckpointAt >= PLAYTIME_CHECKPOINT_INTERVAL_MS &&
playtimeState.usedSeconds !== playtimeLastCheckpointSeconds
@@ -405,8 +521,59 @@ function playtimeTick() {
}
}
+// Updates quietHoursState based on whether "now" falls inside any configured window.
+// Mirror of playtimeTickStep but purely time-window-driven (no counter).
+function quietHoursTickStep() {
+ const cfg = readQuietHoursConfig()
+ if (!cfg.enabled) {
+ if (quietHoursState.state !== 'normal' || quietHoursState.activeWindow !== null) {
+ // User just disabled mid-window: instantly release. Playback isn't auto-started
+ // (it was stopped by the previous block) — kid taps play to resume.
+ quietHoursState.state = 'normal'
+ quietHoursState.graceEndsAt = null
+ quietHoursState.activeWindow = null
+ }
+ return
+ }
+ const now = new Date()
+ const window = findActiveQuietWindow(now, cfg.schedule)
+ if (window) {
+ if (quietHoursState.state === 'normal') {
+ // Just entered a window
+ quietHoursState.activeWindow = window
+ const overrunMs = cfg.maxOverrunMinutes * 60 * 1000
+ if (overrunMs > 0) {
+ quietHoursState.state = 'grace'
+ quietHoursState.graceEndsAt = Date.now() + overrunMs
+ console.log(
+ `${now.toLocaleString()}: [QuietHours] Entered window ${window.from}-${window.to}${window.label ? ` (${window.label})` : ''}. Entering grace period (max ${cfg.maxOverrunMinutes} min).`,
+ )
+ } else {
+ finalizeQuietHoursBlock(`entered window ${window.from}-${window.to} (no grace configured)`)
+ }
+ } else if (quietHoursState.state === 'grace') {
+ if (quietHoursState.graceEndsAt !== null && Date.now() >= quietHoursState.graceEndsAt) {
+ finalizeQuietHoursBlock(`grace period expired (${cfg.maxOverrunMinutes} min)`)
+ }
+ }
+ // 'blocked': stay blocked
+ } else if (quietHoursState.state !== 'normal') {
+ // Just exited a window — release without auto-starting playback
+ console.log(`${now.toLocaleString()}: [QuietHours] Window ended. Playback can resume on user action.`)
+ quietHoursState.state = 'normal'
+ quietHoursState.graceEndsAt = null
+ quietHoursState.activeWindow = null
+ }
+}
+
+function combinedTick() {
+ playtimeTickStep()
+ quietHoursTickStep()
+ writeCombinedWorking()
+}
+
loadPlaytimeCheckpoint()
-setInterval(playtimeTick, 1000)
+setInterval(combinedTick, 1000)
function writeplayerstatePlay() {
playerstate = 'play'
@@ -1258,13 +1425,16 @@ app.use((req, res) => {
log.debug(`${nowDate.toLocaleString()}: [Spotify Control]name: ${command.name}`)
log.debug(`${nowDate.toLocaleString()}: [Spotify Control]dir: ${command.dir}`)
- // Playtime limit: refuse new playback when the daily cap is reached.
+ // Playtime / Quiet-Hours: refuse new playback when either is restricting.
// Pause/stop/volume/system commands fall through normally.
- if (isPlaytimeBlocked() && isPlayInitiatingCommand(command)) {
+ if (isPlaybackBlocked() && isPlayInitiatingCommand(command)) {
+ const inQuiet = quietHoursState.state !== 'normal'
+ const reason = inQuiet ? 'quiet_hours_active' : 'playtime_limit_reached'
+ const tag = inQuiet ? 'QuietHours' : 'Playtime'
console.log(
- `${new Date().toLocaleString()}: [Playtime] Rejected command (limit reached): name=${command.name} dir=${command.dir}`,
+ `${new Date().toLocaleString()}: [${tag}] Rejected command (${reason}): name=${command.name} dir=${command.dir}`,
)
- res.status(423).send({ status: 'blocked', error: 'playtime_limit_reached' })
+ res.status(423).send({ status: 'blocked', error: reason })
return
}
diff --git a/src/frontend-box/src/app/player/player.page.ts b/src/frontend-box/src/app/player/player.page.ts
index c097129f..c81f929e 100644
--- a/src/frontend-box/src/app/player/player.page.ts
+++ b/src/frontend-box/src/app/player/player.page.ts
@@ -362,10 +362,11 @@ export class PlayerPage implements OnInit {
}
// The 30s saveResumeFiles cadence in updateProgress() is fine for normal use, but it
- // can be up to 30 seconds stale when the playtime limit cuts playback off. Save
- // immediately on the entry transition to grace and to blocked so the resume entry
- // captures (close to) the actual stop position. In the last minute before the limit
- // is reached, also save more frequently so the grace-entry save isn't itself stale.
+ // can be up to 30 seconds stale when playback is cut off (playtime cap or quiet
+ // hours window). Save immediately on the entry transition to grace and to blocked
+ // so the resume entry captures (close to) the actual stop position. In the last
+ // minute before the playtime limit, also save more frequently so the grace-entry
+ // save isn't itself stale.
private checkPlaytimeForResume() {
const status = this.playtimeService.status()
if (!status.enabled) {
@@ -377,7 +378,13 @@ export class PlayerPage implements OnInit {
if (cur === 'grace' || cur === 'blocked') {
this.saveResumeFiles()
}
- } else if (cur === 'normal' && this.playing && status.remainingSeconds <= 60 && this.resumeTimer % 5 === 0) {
+ } else if (
+ cur === 'normal' &&
+ this.playing &&
+ status.playtime.enabled &&
+ status.playtime.remainingSeconds <= 60 &&
+ this.resumeTimer % 5 === 0
+ ) {
this.saveResumeFiles()
}
this.prevPlaytimeState = cur
diff --git a/src/frontend-box/src/app/playtime-blocked-overlay/playtime-blocked-overlay.component.html b/src/frontend-box/src/app/playtime-blocked-overlay/playtime-blocked-overlay.component.html
index 09656bc8..c3a7e052 100644
--- a/src/frontend-box/src/app/playtime-blocked-overlay/playtime-blocked-overlay.component.html
+++ b/src/frontend-box/src/app/playtime-blocked-overlay/playtime-blocked-overlay.component.html
@@ -1,10 +1,10 @@
-
-
Heute war genug Musik
+
+
{{ content().heading }}
- Morgen geht's weiter
+ {{ content().subheading }}
diff --git a/src/frontend-box/src/app/playtime-blocked-overlay/playtime-blocked-overlay.component.ts b/src/frontend-box/src/app/playtime-blocked-overlay/playtime-blocked-overlay.component.ts
index d9e23ffb..7fcf81a6 100644
--- a/src/frontend-box/src/app/playtime-blocked-overlay/playtime-blocked-overlay.component.ts
+++ b/src/frontend-box/src/app/playtime-blocked-overlay/playtime-blocked-overlay.component.ts
@@ -1,7 +1,15 @@
-import { ChangeDetectionStrategy, Component } from '@angular/core'
+import { ChangeDetectionStrategy, Component, computed, inject, Signal } from '@angular/core'
import { IonIcon } from '@ionic/angular/standalone'
import { addIcons } from 'ionicons'
-import { moonOutline, musicalNotesOutline } from 'ionicons/icons'
+import { hourglassOutline, moonOutline, musicalNotesOutline } from 'ionicons/icons'
+import type { PlaybackBlockSource } from '../playtime.model'
+import { PlaytimeService } from '../playtime.service'
+
+interface OverlayContent {
+ iconName: string
+ heading: string
+ subheading: string
+}
@Component({
selector: 'mupi-playtime-blocked',
@@ -11,7 +19,39 @@ import { moonOutline, musicalNotesOutline } from 'ionicons/icons'
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PlaytimeBlockedOverlayComponent {
+ private playtimeService = inject(PlaytimeService)
+
+ protected readonly content: Signal = computed(() => {
+ const s = this.playtimeService.status()
+ if (s.enabled !== true) return DEFAULT_PLAYTIME_CONTENT
+ return contentForBlock(s.blockSource, s.quiet.label)
+ })
+
constructor() {
- addIcons({ moonOutline, musicalNotesOutline })
+ addIcons({ moonOutline, musicalNotesOutline, hourglassOutline })
+ }
+}
+
+const DEFAULT_PLAYTIME_CONTENT: OverlayContent = {
+ iconName: 'moon-outline',
+ heading: 'Heute war genug Musik',
+ subheading: "Morgen geht's weiter",
+}
+
+function contentForBlock(source: PlaybackBlockSource | null, quietLabel: string | undefined): OverlayContent {
+ if (source === 'quiet') {
+ if (quietLabel?.trim()) {
+ return {
+ iconName: 'hourglass-outline',
+ heading: quietLabel,
+ subheading: 'Bald gibt es wieder Musik',
+ }
+ }
+ return {
+ iconName: 'moon-outline',
+ heading: 'Ruhezeit',
+ subheading: 'Bald gibt es wieder Musik',
+ }
}
+ return DEFAULT_PLAYTIME_CONTENT
}
diff --git a/src/frontend-box/src/app/playtime-chip/playtime-chip.component.ts b/src/frontend-box/src/app/playtime-chip/playtime-chip.component.ts
index dab010d4..3dcfca34 100644
--- a/src/frontend-box/src/app/playtime-chip/playtime-chip.component.ts
+++ b/src/frontend-box/src/app/playtime-chip/playtime-chip.component.ts
@@ -18,13 +18,15 @@ export class PlaytimeChipComponent {
protected readonly visible: Signal = computed(() => {
const s = this.playtimeService.status()
- return s.enabled === true && s.state === 'normal'
+ // Show only when playtime is enabled AND nothing is restricting playback
+ // (combined state is 'normal'). Quiet-only setups have no countdown to show.
+ return s.enabled === true && s.playtime.enabled && s.state === 'normal'
})
protected readonly remainingMinutes: Signal = computed(() => {
const s = this.playtimeService.status()
- if (s.enabled !== true) return 0
- return Math.ceil(s.remainingSeconds / 60)
+ if (s.enabled !== true || !s.playtime.enabled) return 0
+ return Math.ceil(s.playtime.remainingSeconds / 60)
})
protected readonly level: Signal = computed(() => {
diff --git a/src/frontend-box/src/app/playtime.model.ts b/src/frontend-box/src/app/playtime.model.ts
index c9e933a5..45cb16f4 100644
--- a/src/frontend-box/src/app/playtime.model.ts
+++ b/src/frontend-box/src/app/playtime.model.ts
@@ -2,14 +2,16 @@ export type PlaytimeDayKey = 'mon' | 'tue' | 'wed' | 'thu' | 'fri' | 'sat' | 'su
export type PlaytimePlayState = 'normal' | 'grace' | 'blocked'
-export type PlaytimeStatus = PlaytimeStatusEnabled | PlaytimeStatusDisabled
+export type PlaybackBlockSource = 'playtime' | 'quiet'
+
+export type PlaytimeStatus = PlaytimeStatusActive | PlaytimeStatusDisabled
export interface PlaytimeStatusDisabled {
enabled: false
}
-export interface PlaytimeStatusEnabled {
- enabled: true
+export interface PlaytimeSubStatus {
+ enabled: boolean
state: PlaytimePlayState
date: string
dayKey: PlaytimeDayKey
@@ -19,3 +21,19 @@ export interface PlaytimeStatusEnabled {
graceEndsInSeconds: number
resetHour: number
}
+
+export interface QuietHoursSubStatus {
+ enabled: boolean
+ state: PlaytimePlayState
+ inWindow: boolean
+ label?: string
+ graceEndsInSeconds: number
+}
+
+export interface PlaytimeStatusActive {
+ enabled: true
+ state: PlaytimePlayState
+ blockSource: PlaybackBlockSource | null
+ playtime: PlaytimeSubStatus
+ quiet: QuietHoursSubStatus
+}
From 77ba89b1eca1d66166955b2acb540b05d39c1da3 Mon Sep 17 00:00:00 2001
From: Waldemar Berg
Date: Wed, 6 May 2026 22:44:02 +0200
Subject: [PATCH 14/20] admin: clarify quiet-hours description
(midnight-spanning behavior)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Real user feedback after deploying: a single Monday window of
20:00 → 08:00 covers Monday-evening AND Tuesday-morning, but the
existing description was too terse to make that obvious — first
question was "do I need a second entry for Tuesday?".
Replace the brief "set from later than to to span midnight" sentence
with a bulleted explanation including a concrete worked example, so
the answer is right there in the admin UI.
No code/schema change.
---
AdminInterface/www/mupi.php | 12 ++++++++++--
1 file changed, 10 insertions(+), 2 deletions(-)
diff --git a/AdminInterface/www/mupi.php b/AdminInterface/www/mupi.php
index 252e9ff5..75bd4356 100644
--- a/AdminInterface/www/mupi.php
+++ b/AdminInterface/www/mupi.php
@@ -836,8 +836,16 @@
About
-
Define time windows per weekday in which playback is automatically blocked (e.g. homework time, mealtimes, bedtime). Multiple windows per day are supported. Settings take effect after saving (the player is restarted automatically).
-
Set a window's from later than its to to span midnight (e.g. 20:00 → 06:00 covers the night). The optional label is shown to the kid on the block screen.
+
Define time windows per weekday during which playback is automatically blocked (e.g. homework, mealtimes, bedtime). Multiple windows per day are supported.
+
How a window is interpreted:
+
+
A window belongs to the day it starts on.
+
If from is later than to, the window automatically continues into the next morning.
+
Example: a single entry on Monday with from 20:00 → to 08:00 blocks playback Monday evening and Tuesday morning until 08:00. You do not need a separate Tuesday entry for the same night.
+
Multiple windows on the same day combine — e.g. add a 14:00 → 16:00 (Homework) alongside 20:00 → 08:00 (Bedtime) to block both periods.
+
The optional label is shown to the kid on the block screen (e.g. „Homework", „Bedtime").
+
+
Settings take effect after saving (the player is restarted automatically).
Status
From df4a122e848253a7ef167c15ead55255d47ba2b4 Mon Sep 17 00:00:00 2001
From: Waldemar Berg
Date: Fri, 15 May 2026 16:52:26 +0200
Subject: [PATCH 15/20] fix(quiet): suppress quiet-hours state mutation during
allowUntil override (AR5-14)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
When a parent grants playback via /release allowUntil, the existing
guards in finalizeQuietHoursBlock (line 668) and isBlockActive
(line 635) correctly stop NEW blocking events from firing during the
override. But quietHoursTickStep itself still mutated quietHoursState
when a window started or ended mid-override:
16:00 parent runs /release allowUntil 20:00 (kid plays through dinner)
18:00 quiet window 18-20 starts. tickStep enters its `state==='normal'
&& window matches` branch, sets state='grace' with maxOverrun grace
18:30 grace expires → state='blocked'
20:00 override expires. Next isBlockActive() call: state is already
'blocked', kid gets cut off with zero grace at the very moment
the parent expected playback to gracefully wind down.
Fix: gate the entire tickStep behind isAllowOverrideActive(). State
stays on 'normal' / activeWindow stays null for the whole override
window. The first tick after the override expires sees the current
time-in-quiet-window properly and walks the normal → grace → blocked
transitions from scratch — so the kid actually gets the configured
maxOverrunMinutes of grace.
playtimeTickStep is intentionally left alone — playtime accounting
needs to keep ticking through an override so the daily counter
reflects real usage; only its BLOCKING side is suppressed, which
the existing isAllowOverrideActive guards already handle.
---
src/backend-player/src/spotify-control.js | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/src/backend-player/src/spotify-control.js b/src/backend-player/src/spotify-control.js
index 26683795..3df8f5b2 100644
--- a/src/backend-player/src/spotify-control.js
+++ b/src/backend-player/src/spotify-control.js
@@ -524,6 +524,14 @@ function playtimeTickStep() {
// Updates quietHoursState based on whether "now" falls inside any configured window.
// Mirror of playtimeTickStep but purely time-window-driven (no counter).
function quietHoursTickStep() {
+ // AR5-14: parent's allowUntil override suppresses ALL state transitions —
+ // not just blocked-entry. Without this, a quiet window that starts mid-
+ // override would silently mutate state to 'grace' or 'blocked'; the
+ // moment the override ended, the kid would be hit with no grace at all
+ // (state already 'blocked'). Skipping the tick keeps state at 'normal'
+ // throughout the override, so the post-override tick walks the proper
+ // normal -> grace -> blocked path again.
+ if (isAllowOverrideActive()) return
const cfg = readQuietHoursConfig()
if (!cfg.enabled) {
if (quietHoursState.state !== 'normal' || quietHoursState.activeWindow !== null) {
From 26396346b0a5c6d27f772e53ee5589331b6f3875 Mon Sep 17 00:00:00 2001
From: Waldemar Berg
Date: Wed, 6 May 2026 23:48:57 +0200
Subject: [PATCH 16/20] telegram receiver: enforce chatId-based authorization
(security fix)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The receiver previously responded to commands from any chat that
messaged the bot. Anyone who discovered the bot username (e.g. via
Telegram search, by being in a shared group, or by guessing) could
send /shutdown, /reboot, /vol, /sleep, /screen — with the bot token
as the *only* barrier. That is too thin a defense for a parent's
network-exposed device.
Adds an is_authorized() check that compares the inbound chat_id /
user_id against the chatId configured in mupiboxconfig.json. An
empty chatId is treated as "deny all" — the user has to configure
one anyway for outbound notifications, so requiring it here costs
nothing.
Applies to both on_chat_message and on_callback_query (the inline
keyboard) so neither path is privileged.
This fix can be backported to any existing MuPiBox installation that
runs telegram_receiver.py — no other changes required, no schema
changes, no service config changes.
---
scripts/telegram/telegram_receiver.py | 19 +++++++++++++++++++
1 file changed, 19 insertions(+)
diff --git a/scripts/telegram/telegram_receiver.py b/scripts/telegram/telegram_receiver.py
index b60adb8b..ffca0e3b 100644
--- a/scripts/telegram/telegram_receiver.py
+++ b/scripts/telegram/telegram_receiver.py
@@ -15,11 +15,27 @@
if not config['telegram']['active']:
quit()
+# Authorization: only respond to messages from the configured chat. Without
+# this check anyone who discovers the bot username can send /shutdown,
+# /reboot, /vol etc. — the bot token is the only barrier, which is too thin.
+# An empty chatId is treated as "deny all": the user has to configure one
+# for outbound notifications anyway, so requiring it here loses nothing.
+ALLOWED_CHAT_ID = str(config['telegram'].get('chatId', '')).strip()
+
+def is_authorized(chat_id):
+ if not ALLOWED_CHAT_ID:
+ print('Refusing message: no chatId configured in mupiboxconfig.json')
+ return False
+ return str(chat_id) == ALLOWED_CHAT_ID
+
message_with_inline_keyboard = None
def on_chat_message(msg):
content_type, chat_type, chat_id = telepot.glance(msg)
print(content_type, chat_type, chat_id)
+ if not is_authorized(chat_id):
+ print(f'Rejected message from unauthorized chat_id: {chat_id}')
+ return
if content_type != 'text':
return
command = msg['text']
@@ -73,6 +89,9 @@ def on_chat_message(msg):
def on_callback_query(msg):
query_id, from_id, query_data = telepot.glance(msg, flavor='callback_query')
print('Callback Query:', query_id, from_id, query_data)
+ if not is_authorized(from_id):
+ print(f'Rejected callback from unauthorized user_id: {from_id}')
+ return
global message_with_inline_keyboard
From d4fe91cc11f4aa5e358455b9e1e8bddecdf83d45 Mon Sep 17 00:00:00 2001
From: Waldemar Berg
Date: Wed, 6 May 2026 22:58:27 +0200
Subject: [PATCH 17/20] backend-player: live-reload mupiboxconfig.json (no more
pm2 restart on save)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Replaces the require()-cached mupiboxconfig.json with a fs.watch-based
live reloader. Admin saves of playtime/quiet-hours now take effect
within ~50ms instead of triggering a 3-5s pm2 restart of the player
(which dropped audio and disconnected librespot).
How it works:
- readMupiBoxConfigFromDisk() reads + JSON.parses on demand. Failures
log and return null so partial-write events don't replace good
state with garbage.
- muPiBoxConfig is now a `let` binding, atomically reassigned on each
successful reload. All access patterns (`muPiBoxConfig.telegram.X`,
`muPiBoxConfig?.playtimeLimit`, etc.) dereference per-access, so
swapping the binding is transparent.
- The file at the local path is typically a symlink to
/etc/mupibox/mupiboxconfig.json on the box. We resolve the symlink
via realpathSync and watch the *resolved directory* — admin uses
atomic `mv` rename, which fires directory-level events that
watching the symlink directly would miss.
- fs.watch uses inotify on Linux; near-zero idle CPU and no polling.
config.json (Spotify creds, log level, port) intentionally NOT
live-reloaded: those values are sealed into spotifyApi/log/server.listen()
at startup, so changing them still requires a pm2 restart anyway.
Admin: drop the `pm2 restart spotify-control` block that ran after
playtime/quiet-hours saves; with live-reload it's redundant. Update
the success messages to reflect the new behavior.
---
AdminInterface/www/mupi.php | 16 +++---
src/backend-player/src/spotify-control.js | 60 +++++++++++++++++++++--
2 files changed, 65 insertions(+), 11 deletions(-)
diff --git a/AdminInterface/www/mupi.php b/AdminInterface/www/mupi.php
index 75bd4356..2cd90f2c 100644
--- a/AdminInterface/www/mupi.php
+++ b/AdminInterface/www/mupi.php
@@ -468,7 +468,7 @@
$data["playtimeLimit"]["limitsMinutes"][$d] = max(0, min(1440, $val));
}
$playtime_changed = true;
- $CHANGE_TXT = $CHANGE_TXT."
Quiet hours saved (".$quiethours_window_count." window(s), live, no restart needed)
";
$change = 2;
}
if( $data["shim"]["ledPin"]!=$_POST['ledPin'] && $_POST['ledPin'])
@@ -638,11 +638,11 @@
exec("sudo mv /tmp/.mupiboxconfig.json /etc/mupibox/mupiboxconfig.json");
exec("sudo /usr/local/bin/mupibox/./setting_update.sh");
}
- if( $playtime_changed )
- {
- // Player caches mupiboxconfig.json at startup via require(); restart so the new playtime values take effect.
- exec("sudo -i -u dietpi pm2 restart spotify-control");
- }
+ // 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."
";
?>
diff --git a/src/backend-player/src/spotify-control.js b/src/backend-player/src/spotify-control.js
index 3df8f5b2..bbb78c83 100644
--- a/src/backend-player/src/spotify-control.js
+++ b/src/backend-player/src/spotify-control.js
@@ -20,7 +20,63 @@ if (process.env.NODE_ENV === 'development') {
//networkConfigBasePath = '../../backend-api/config'
}
-const muPiBoxConfig = require(`${configBasePath}/mupiboxconfig.json`)
+// mupiboxconfig.json supports live reload — admin saves take effect within ~50ms
+// without a pm2 restart. config.json (Spotify creds, log level, port) is read once
+// at startup because spotifyApi/log/server.listen() seal those values; changing those
+// still requires a pm2 restart.
+const MUPIBOX_CONFIG_PATH = `${configBasePath}/mupiboxconfig.json`
+
+function readMupiBoxConfigFromDisk() {
+ try {
+ return JSON.parse(fs.readFileSync(MUPIBOX_CONFIG_PATH, 'utf8'))
+ } catch (err) {
+ console.error(`${new Date().toLocaleString()}: [Config] Failed to read ${MUPIBOX_CONFIG_PATH}:`, err)
+ return null
+ }
+}
+
+let muPiBoxConfig = readMupiBoxConfigFromDisk()
+if (!muPiBoxConfig) {
+ console.error(
+ `${new Date().toLocaleString()}: [Config] mupiboxconfig.json missing or unparseable on startup, exiting.`,
+ )
+ process.exit(1)
+}
+
+// Watch the directory containing the resolved file (the local path is typically a symlink
+// to /etc/mupibox/mupiboxconfig.json on the box). Watching the directory rather than the
+// symlinked file is what makes atomic-rename writes (admin uses `mv tmp dest`) trigger.
+function setupMupiBoxConfigWatch() {
+ let watchDir
+ let watchFile
+ try {
+ const realPath = fs.realpathSync(MUPIBOX_CONFIG_PATH)
+ watchDir = path.dirname(realPath)
+ watchFile = path.basename(realPath)
+ } catch (err) {
+ console.warn(
+ `${new Date().toLocaleString()}: [Config] Cannot resolve ${MUPIBOX_CONFIG_PATH} for watch (live-reload disabled):`,
+ err,
+ )
+ return
+ }
+ try {
+ fs.watch(watchDir, { persistent: false }, (_event, filename) => {
+ if (!filename || filename.toString() !== watchFile) return
+ const fresh = readMupiBoxConfigFromDisk()
+ if (fresh) {
+ muPiBoxConfig = fresh
+ console.log(`${new Date().toLocaleString()}: [Config] Reloaded mupiboxconfig.json (live)`)
+ }
+ // On parse failure we keep the old in-memory copy — fs.watch can fire mid-write.
+ })
+ console.log(`${new Date().toLocaleString()}: [Config] Watching ${watchDir}/${watchFile} for live-reload`)
+ } catch (err) {
+ console.warn(`${new Date().toLocaleString()}: [Config] fs.watch failed (live-reload disabled):`, err)
+ }
+}
+setupMupiBoxConfigWatch()
+
const config = require(`${configBasePath}/config.json`)
const log = require('console-log-level')({ level: config.server.logLevel })
@@ -415,8 +471,6 @@ function isPlaybackBlocked() {
quietHoursState.state === 'blocked'
)
}
-// Backwards-compat alias kept so older call sites keep working.
-const isPlaytimeBlocked = isPlaybackBlocked
// Transition to fully-stopped state. Called from the tick on grace timeout, from the
// mplayer track-change/playlist-finish handlers, or directly when grace=0.
From f0df3dce8a2985b715b4118912aad7732105be65 Mon Sep 17 00:00:00 2001
From: Waldemar Berg
Date: Wed, 6 May 2026 23:49:29 +0200
Subject: [PATCH 18/20] telegram parent controls: /status, /extend, /release,
/quietnow
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds remote parent control commands to the Telegram bot, backed by
new backend-api endpoints and player overrides. Authorization is
already in place from the prior commit.
Three config-driven mechanisms wire it up end-to-end:
1. playtimeLimit.todayBonus { date, minutes }
Bonus minutes added to today's cap, auto-resetting at day rollover
(the player checks if the stored date matches the current logical
day). Used by /extend so giving extra time today doesn't change
the persistent weekday limit.
2. playbackOverride.allowUntil (epoch ms)
While now < allowUntil, isPlaybackBlocked() returns false and
finalizePlaytimeBlock / finalizeQuietHoursBlock short-circuit —
parent has explicitly green-lit playback. Used by /release.
3. playbackOverride.forceBlockUntil (epoch ms)
While now < forceBlockUntil, the combined tick short-circuits to
'blocked' and stop() is called once on entry. New blockSource
value 'override' lets the frontend show a distinct overlay if it
wants. Used by /quietnow ("instant bedtime").
API additions in backend-api:
- POST /api/playtime/extend body: { minutes }
- POST /api/playtime/release body: { minutes? = 60 }
- POST /api/quiethours/now body: { minutes? = 60 }
All three reach into mupiboxconfig.json via a small atomic helper
(updateMupiboxConfig) that mutates an in-memory copy and `sudo cp`s
it over the live file. The player picks up the change ~50 ms later
via fs.watch (already in place from the live-reload commit).
Telegram receiver:
- New /status command formats the /api/playtime payload into a
human-readable summary (used minutes, remaining, quiet-window
active, override countdowns, etc.).
- New /extend, /release, /quietnow commands with sensible defaults
(30, 60, 60 minutes respectively).
- Inline keyboard /help gets quick buttons for the four new actions.
Schema changes are additive; existing installations with no
playbackOverride or todayBonus keys are treated as defaults (no
override active, no bonus). The override pair both default to 0,
which is always in the past — so "no override" is the natural state.
---
config/templates/mupiboxconfig.json | 4 +
scripts/telegram/telegram_receiver.py | 168 ++++++++++++++++--
.../src/models/mupibox-config.model.ts | 3 +-
src/backend-api/src/models/playtime.model.ts | 45 ++++-
src/backend-api/src/server.ts | 124 +++++++++++++
src/backend-player/src/spotify-control.js | 128 +++++++++++--
src/frontend-box/src/app/playtime.model.ts | 6 +-
7 files changed, 439 insertions(+), 39 deletions(-)
diff --git a/config/templates/mupiboxconfig.json b/config/templates/mupiboxconfig.json
index 638a2d2b..20ecb265 100644
--- a/config/templates/mupiboxconfig.json
+++ b/config/templates/mupiboxconfig.json
@@ -292,6 +292,10 @@
"sun": []
}
},
+ "playbackOverride": {
+ "allowUntil": 0,
+ "forceBlockUntil": 0
+ },
"shim": {
"poweroffPin": "4",
"triggerPin": "17",
diff --git a/scripts/telegram/telegram_receiver.py b/scripts/telegram/telegram_receiver.py
index ffca0e3b..20793227 100644
--- a/scripts/telegram/telegram_receiver.py
+++ b/scripts/telegram/telegram_receiver.py
@@ -15,19 +15,90 @@
if not config['telegram']['active']:
quit()
-# Authorization: only respond to messages from the configured chat. Without
-# this check anyone who discovers the bot username can send /shutdown,
-# /reboot, /vol etc. — the bot token is the only barrier, which is too thin.
-# An empty chatId is treated as "deny all": the user has to configure one
-# for outbound notifications anyway, so requiring it here loses nothing.
+# Authorization: only respond to messages from the configured chat. Without this
+# anyone who learns the bot username could send /shutdown / /quietnow / etc.
+# An empty chatId is treated as "deny all" — the user has to configure one for
+# outbound notifications anyway.
ALLOWED_CHAT_ID = str(config['telegram'].get('chatId', '')).strip()
+# Backend-API base URL on the same host. Used for parent-control commands
+# (extend / release / quietnow / status). The player listens for config
+# changes via fs.watch, so changes apply within ~50 ms.
+API_BASE = 'http://localhost:8200/api'
+
def is_authorized(chat_id):
if not ALLOWED_CHAT_ID:
print('Refusing message: no chatId configured in mupiboxconfig.json')
return False
return str(chat_id) == ALLOWED_CHAT_ID
+def fmt_minutes(seconds):
+ if seconds <= 0:
+ return '0 min'
+ if seconds < 60:
+ return '<1 min'
+ return f'{seconds // 60} min'
+
+def parse_int_arg(command, default=None):
+ parts = command.split()
+ if len(parts) >= 2:
+ try:
+ return int(parts[1])
+ except (ValueError, TypeError):
+ return default
+ return default
+
+def call_api_post(path, body=None):
+ try:
+ r = requests.post(f'{API_BASE}{path}', json=(body or {}), timeout=5)
+ return r.status_code, r.json() if r.headers.get('content-type', '').startswith('application/json') else r.text
+ except Exception as e:
+ return 0, str(e)
+
+def call_api_get(path):
+ try:
+ r = requests.get(f'{API_BASE}{path}', timeout=5)
+ return r.status_code, r.json() if r.headers.get('content-type', '').startswith('application/json') else r.text
+ except Exception as e:
+ return 0, str(e)
+
+def format_status(status):
+ if not status or status.get('enabled') is False:
+ return 'Playtime/Quiet hours: not configured'
+ state = status.get('state', 'normal')
+ block_source = status.get('blockSource')
+ pt = status.get('playtime', {}) or {}
+ qh = status.get('quiet', {}) or {}
+ ovr = status.get('override', {}) or {}
+ lines = []
+ if state == 'normal':
+ lines.append('Status: OK (Wiedergabe erlaubt)')
+ elif state == 'grace':
+ lines.append(f'Status: Grace (Track läuft aus, Quelle: {block_source})')
+ elif state == 'blocked':
+ lines.append(f'Status: BLOCKIERT (Quelle: {block_source})')
+ if pt.get('enabled'):
+ used = int(pt.get('usedSeconds', 0))
+ rem = int(pt.get('remainingSeconds', 0))
+ limit = int(pt.get('limitMinutes', 0))
+ lines.append(f'Playtime: {used // 60}/{limit} min, noch {fmt_minutes(rem)}')
+ if qh.get('enabled'):
+ if qh.get('inWindow'):
+ label = qh.get('label', '')
+ lines.append(f'Quiet hours: aktiv{f" ({label})" if label else ""}')
+ else:
+ lines.append('Quiet hours: ein, aber gerade kein Fenster aktiv')
+ now_ms = int(time.time() * 1000)
+ if int(ovr.get('forceBlockUntil', 0)) > now_ms:
+ until = int(ovr['forceBlockUntil'])
+ mins = (until - now_ms) // 60000
+ lines.append(f'⛔ Force-Block aktiv für noch {mins} min')
+ if int(ovr.get('allowUntil', 0)) > now_ms:
+ until = int(ovr['allowUntil'])
+ mins = (until - now_ms) // 60000
+ lines.append(f'✅ Override aktiv für noch {mins} min')
+ return '\n'.join(lines)
+
message_with_inline_keyboard = None
def on_chat_message(msg):
@@ -57,19 +128,57 @@ def on_chat_message(msg):
sleep = int(split_cmd[1]) * 60
subprocess.Popen(["sudo", "nohup", "/usr/local/bin/mupibox/./sleep_timer.sh", str(sleep)])
bot.sendMessage(chat_id, "Sleep timer set to "+split_cmd[1]+" minutes")
+ elif command == '/status':
+ status_code, body = call_api_get('/playtime')
+ if status_code == 200:
+ bot.sendMessage(chat_id, format_status(body), parse_mode='HTML')
+ else:
+ bot.sendMessage(chat_id, f'Status-Abfrage fehlgeschlagen: {status_code} {body}')
+ elif command[:7] == '/extend':
+ mins = parse_int_arg(command, default=30)
+ if mins is None or mins <= 0 or mins > 1440:
+ bot.sendMessage(chat_id, 'Nutzung: /extend (1..1440)')
+ else:
+ status_code, body = call_api_post('/playtime/extend', {'minutes': mins})
+ if status_code == 200:
+ bot.sendMessage(chat_id, f'✅ Heute +{mins} min Bonus hinzugefügt.')
+ else:
+ bot.sendMessage(chat_id, f'Fehler: {status_code} {body}')
+ elif command[:8] == '/release':
+ mins = parse_int_arg(command, default=60)
+ if mins is None or mins <= 0 or mins > 1440:
+ bot.sendMessage(chat_id, 'Nutzung: /release (1..1440)')
+ else:
+ status_code, body = call_api_post('/playtime/release', {'minutes': mins})
+ if status_code == 200:
+ bot.sendMessage(chat_id, f'✅ Override aktiv für {mins} min — alle Blocks aus.')
+ else:
+ bot.sendMessage(chat_id, f'Fehler: {status_code} {body}')
+ elif command[:9] == '/quietnow':
+ mins = parse_int_arg(command, default=60)
+ if mins is None or mins <= 0 or mins > 1440:
+ bot.sendMessage(chat_id, 'Nutzung: /quietnow (1..1440)')
+ else:
+ status_code, body = call_api_post('/quiethours/now', {'minutes': mins})
+ if status_code == 200:
+ bot.sendMessage(chat_id, f'⛔ Sofort-Stopp für {mins} min aktiviert.')
+ else:
+ bot.sendMessage(chat_id, f'Fehler: {status_code} {body}')
elif command == '/help':
markup = InlineKeyboardMarkup(inline_keyboard=[
- [InlineKeyboardButton(text="Current Screen",callback_data='screen'), InlineKeyboardButton(text="Set Volume",callback_data='vol')],
+ [InlineKeyboardButton(text="Status",callback_data='status'), InlineKeyboardButton(text="Current Screen",callback_data='screen')],
[InlineKeyboardButton(text="Pause",callback_data='pause'), InlineKeyboardButton(text="Play",callback_data='play')],
- [InlineKeyboardButton(text="Sleep Timer",callback_data='sleep'), InlineKeyboardButton(text="Finish current album",callback_data='finishalbum')],
- [InlineKeyboardButton(text="Shutdown",callback_data='shutdown'), InlineKeyboardButton(text="Reboot",callback_data='reboot')],
- [InlineKeyboardButton(text="Update Media-DB",callback_data='media')]
+ [InlineKeyboardButton(text="+30 min",callback_data='extend_30'), InlineKeyboardButton(text="+60 min",callback_data='extend_60')],
+ [InlineKeyboardButton(text="Release 60",callback_data='release_60'), InlineKeyboardButton(text="QuietNow 60",callback_data='quietnow_60')],
+ [InlineKeyboardButton(text="Set Volume",callback_data='vol'), InlineKeyboardButton(text="Sleep Timer",callback_data='sleep')],
+ [InlineKeyboardButton(text="Finish current album",callback_data='finishalbum'), InlineKeyboardButton(text="Update Media-DB",callback_data='media')],
+ [InlineKeyboardButton(text="Shutdown",callback_data='shutdown'), InlineKeyboardButton(text="Reboot",callback_data='reboot')]
]
)
global message_with_inline_keyboard
message_with_inline_keyboard = bot.sendMessage(chat_id, 'Possible commands:',reply_markup = markup)
elif command == '/command':
- bot.sendMessage(chat_id, "Possible commands:\n\n/help\nto get easy way to use command\n\n/reboot\nto reboot\n\n/shutdown\nto shutdown the mupibox\n\n/screen\nto get a current screenshot\n\n/sleep [No in minutes]\nto set a sleep timer in minutes\nExample: /sleep 30\n\n/vol [No in percent (0-100)]\nto set a sleep timer in minutes\nExample: /vol 30\n\n/media\nUpdate media database...\n\n/finishalbum\nActivates the shutdown after the end of the current album.",parse_mode='HTML')
+ bot.sendMessage(chat_id, "Possible commands:\n\n/help\nshows the inline keyboard\n\n/status\nshow current playtime + quiet hours status\n\n/extend[minutes, default 30]\nadd bonus minutes to today's playtime cap\n\n/release[minutes, default 60]\nbypass all blocks for N minutes\n\n/quietnow[minutes, default 60]\nforce-block playback for N minutes\n\n/reboot\n/shutdown\n/screen\n/sleep[minutes]\n/vol[0-100]\n/media\n/finishalbum", parse_mode='HTML')
elif command == '/media':
bot.sendMessage(chat_id, "Starting media data update... This take a while, please wait for complete message")
subprocess.run(["sudo", "/usr/local/bin/mupibox/./m3u_generator.sh"])
@@ -99,6 +208,33 @@ def on_callback_query(msg):
subprocess.run(["sudo", "rm", "/tmp/telegram_screen.png"])
subprocess.run(["sudo", "-H", "-u", "dietpi", "bash", "-c", "DISPLAY=:0 scrot /tmp/telegram_screen.png"])
bot.sendPhoto(from_id, open('/tmp/telegram_screen.png', 'rb'))
+ elif query_data == 'status':
+ status_code, body = call_api_get('/playtime')
+ if status_code == 200:
+ bot.sendMessage(from_id, format_status(body), parse_mode='HTML')
+ else:
+ bot.answerCallbackQuery(query_id, text=f'Status-Abfrage fehlgeschlagen: {status_code}', show_alert=True)
+ elif query_data[:7] == 'extend_':
+ mins = int(query_data.split('_')[1])
+ status_code, body = call_api_post('/playtime/extend', {'minutes': mins})
+ if status_code == 200:
+ bot.answerCallbackQuery(query_id, text=f'+{mins} min hinzugefügt', show_alert=True)
+ else:
+ bot.answerCallbackQuery(query_id, text=f'Fehler: {status_code}', show_alert=True)
+ elif query_data[:8] == 'release_':
+ mins = int(query_data.split('_')[1])
+ status_code, body = call_api_post('/playtime/release', {'minutes': mins})
+ if status_code == 200:
+ bot.answerCallbackQuery(query_id, text=f'Override für {mins} min aktiv', show_alert=True)
+ else:
+ bot.answerCallbackQuery(query_id, text=f'Fehler: {status_code}', show_alert=True)
+ elif query_data[:9] == 'quietnow_':
+ mins = int(query_data.split('_')[1])
+ status_code, body = call_api_post('/quiethours/now', {'minutes': mins})
+ if status_code == 200:
+ bot.answerCallbackQuery(query_id, text=f'Stop für {mins} min aktiviert', show_alert=True)
+ else:
+ bot.answerCallbackQuery(query_id, text=f'Fehler: {status_code}', show_alert=True)
elif query_data == 'vol':
markup = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="10",callback_data='vol_10'), InlineKeyboardButton(text="20",callback_data='vol_20')],
@@ -139,15 +275,17 @@ def on_callback_query(msg):
requests.get(url)
elif query_data == 'back':
markup = InlineKeyboardMarkup(inline_keyboard=[
- [InlineKeyboardButton(text="Current Screen",callback_data='screen'), InlineKeyboardButton(text="Set Volume",callback_data='vol')],
+ [InlineKeyboardButton(text="Status",callback_data='status'), InlineKeyboardButton(text="Current Screen",callback_data='screen')],
[InlineKeyboardButton(text="Pause",callback_data='pause'), InlineKeyboardButton(text="Play",callback_data='play')],
- [InlineKeyboardButton(text="Sleep Timer",callback_data='sleep'), InlineKeyboardButton(text="Finish current album",callback_data='finishalbum')],
- [InlineKeyboardButton(text="Shutdown",callback_data='shutdown'), InlineKeyboardButton(text="Reboot",callback_data='reboot')],
- [InlineKeyboardButton(text="Update Media-DB",callback_data='media')]
+ [InlineKeyboardButton(text="+30 min",callback_data='extend_30'), InlineKeyboardButton(text="+60 min",callback_data='extend_60')],
+ [InlineKeyboardButton(text="Release 60",callback_data='release_60'), InlineKeyboardButton(text="QuietNow 60",callback_data='quietnow_60')],
+ [InlineKeyboardButton(text="Set Volume",callback_data='vol'), InlineKeyboardButton(text="Sleep Timer",callback_data='sleep')],
+ [InlineKeyboardButton(text="Finish current album",callback_data='finishalbum'), InlineKeyboardButton(text="Update Media-DB",callback_data='media')],
+ [InlineKeyboardButton(text="Shutdown",callback_data='shutdown'), InlineKeyboardButton(text="Reboot",callback_data='reboot')]
]
)
msg_idf = telepot.message_identifier(message_with_inline_keyboard)
- bot.editMessageText(msg_idf, 'What volume should set?', reply_markup = markup )
+ bot.editMessageText(msg_idf, 'Possible commands:', reply_markup = markup )
elif query_data == 'finishalbum':
bot.answerCallbackQuery(query_id, text='After finishing the current album the MuPiBox will be shut down.', show_alert=True)
bot.sendMessage(from_id, "After finishing the current album the MuPiBox will be shut down.")
diff --git a/src/backend-api/src/models/mupibox-config.model.ts b/src/backend-api/src/models/mupibox-config.model.ts
index 8a74265b..79f94aa8 100644
--- a/src/backend-api/src/models/mupibox-config.model.ts
+++ b/src/backend-api/src/models/mupibox-config.model.ts
@@ -1,4 +1,4 @@
-import type { PlaytimeLimitConfig, QuietHoursConfig } from './playtime.model'
+import type { PlaybackOverrideConfig, PlaytimeLimitConfig, QuietHoursConfig } from './playtime.model'
export interface MupiboxConfig {
spotify?: {
@@ -7,5 +7,6 @@ export interface MupiboxConfig {
}
playtimeLimit?: PlaytimeLimitConfig
quietHours?: QuietHoursConfig
+ playbackOverride?: PlaybackOverrideConfig
[key: string]: unknown
}
diff --git a/src/backend-api/src/models/playtime.model.ts b/src/backend-api/src/models/playtime.model.ts
index 2eff0d8e..7892bbc0 100644
--- a/src/backend-api/src/models/playtime.model.ts
+++ b/src/backend-api/src/models/playtime.model.ts
@@ -2,11 +2,22 @@ export type PlaytimeDayKey = 'mon' | 'tue' | 'wed' | 'thu' | 'fri' | 'sat' | 'su
export type PlaytimeLimitsMinutes = Partial>
+export interface PlaytimeBonus {
+ date: string // YYYY-MM-DD; only honored if matches today's logical day
+ minutes: number
+}
+
export interface PlaytimeLimitConfig {
enabled: boolean
resetHour?: number
maxOverrunMinutes?: number
limitsMinutes?: PlaytimeLimitsMinutes
+ todayBonus?: PlaytimeBonus
+}
+
+export interface PlaybackOverrideConfig {
+ allowUntil?: number // epoch ms; while now < this, all blocks are bypassed
+ forceBlockUntil?: number // epoch ms; while now < this, playback is forced-blocked
}
// === Quiet Hours ===
@@ -33,21 +44,19 @@ export interface QuietHoursConfig {
export type PlaytimePlayState = 'normal' | 'grace' | 'blocked'
// Identifies *what* is currently restricting playback (when state !== 'normal').
-// 'playtime' = daily-limit-based, 'quiet' = time-window-based.
-export type PlaybackBlockSource = 'playtime' | 'quiet'
+// 'playtime' = daily-limit-based, 'quiet' = time-window-based, 'override' = parent
+// triggered an explicit force-block (e.g. via Telegram /quietnow).
+export type PlaybackBlockSource = 'playtime' | 'quiet' | 'override'
-export type PlaytimeStatus = PlaytimeStatusEnabled | PlaytimeStatusDisabled
+export type PlaytimeStatus = PlaytimeStatusActive | PlaytimeStatusDisabled
export interface PlaytimeStatusDisabled {
enabled: false
}
-export interface PlaytimeStatusEnabled {
- enabled: true
+export interface PlaytimeSubStatus {
+ enabled: boolean
state: PlaytimePlayState
- blockSource: PlaybackBlockSource | null
- // Set when blockSource === 'quiet' and the active window has a label.
- quietLabel?: string
date: string
dayKey: PlaytimeDayKey
limitMinutes: number
@@ -56,3 +65,23 @@ export interface PlaytimeStatusEnabled {
graceEndsInSeconds: number
resetHour: number
}
+
+export interface QuietHoursSubStatus {
+ enabled: boolean
+ state: PlaytimePlayState
+ inWindow: boolean
+ label?: string
+ graceEndsInSeconds: number
+}
+
+export interface PlaytimeStatusActive {
+ enabled: true
+ state: PlaytimePlayState
+ blockSource: PlaybackBlockSource | null
+ playtime: PlaytimeSubStatus
+ quiet: QuietHoursSubStatus
+ override?: {
+ allowUntil: number
+ forceBlockUntil: number
+ }
+}
diff --git a/src/backend-api/src/server.ts b/src/backend-api/src/server.ts
index de246320..b4ceef7d 100644
--- a/src/backend-api/src/server.ts
+++ b/src/backend-api/src/server.ts
@@ -185,6 +185,130 @@ app.get('/api/playtime', (_req, res) => {
})
})
+// Atomically apply a mutation to /etc/mupibox/mupiboxconfig.json.
+// Used by the parent-control endpoints below (extend / release / quietnow).
+// The player picks up the change ~50 ms later via fs.watch (see spotify-control.js).
+async function updateMupiboxConfig(mutate: (cfg: Record) => void): Promise {
+ const current = (await readJsonFile(mupiboxConfigPath)) as Record
+ mutate(current)
+ const tmpPath = '/tmp/.mupiboxconfig.update.json'
+ await new Promise((resolve, reject) => {
+ jsonfile.writeFile(tmpPath, current, { spaces: 2 }, (err) => (err ? reject(err) : resolve()))
+ })
+ await new Promise((resolve, reject) => {
+ // sudo cp is allowed for the dietpi user on the box (same pattern as
+ // /api/shutdown / /api/reboot below). Atomic: write to a tmp on the same
+ // filesystem region, then cp into place; player's fs.watch fires once.
+ exec(`sudo cp ${tmpPath} ${mupiboxConfigPath} && sudo rm -f ${tmpPath}`, (err) => (err ? reject(err) : resolve()))
+ })
+ // Local cache invalidation (server's own mupiboxConfigCache) — fs.watch on the
+ // dir already does this, but be explicit so /api/config returns the new value
+ // immediately on the next call.
+ mupiboxConfigCache = undefined
+}
+
+// Logical-day computation must match the player's `getLogicalDay` so `todayBonus`
+// works consistently across processes (resetHour shifts when "today" begins).
+function computeLogicalDate(now: Date, resetHour: number): string {
+ const shifted = new Date(now.getTime() - resetHour * 3600 * 1000)
+ const y = shifted.getFullYear()
+ const m = String(shifted.getMonth() + 1).padStart(2, '0')
+ const d = String(shifted.getDate()).padStart(2, '0')
+ return `${y}-${m}-${d}`
+}
+
+// POST /api/playtime/extend body: { minutes: number }
+// Adds bonus minutes to today's playtime cap. If the day rolls over at the
+// configured resetHour, the bonus auto-clears (player checks the date field).
+// Calling extend repeatedly accumulates: existing bonus for today is kept and
+// added to. Always uses the *current* day at the time of call, so e.g. an
+// /extend at 23:30 with resetHour=4 still applies to "today" until 04:00.
+app.post('/api/playtime/extend', async (req, res) => {
+ const minutes = Number(req.body?.minutes)
+ if (!Number.isFinite(minutes) || minutes <= 0 || minutes > 1440) {
+ res.status(400).json({ error: 'minutes must be a positive number <= 1440' })
+ return
+ }
+ try {
+ await updateMupiboxConfig((cfg) => {
+ let pl = cfg.playtimeLimit as Record | undefined
+ if (!pl || typeof pl !== 'object') {
+ pl = {}
+ cfg.playtimeLimit = pl
+ }
+ const resetHour = Number.isInteger(pl.resetHour) ? (pl.resetHour as number) : 0
+ const today = computeLogicalDate(new Date(), resetHour)
+ const existing = (pl.todayBonus as { date?: string; minutes?: number } | undefined) || {}
+ const existingMinutes =
+ existing.date === today && Number.isFinite(existing.minutes) ? Number(existing.minutes) : 0
+ pl.todayBonus = { date: today, minutes: Math.min(1440, existingMinutes + minutes) }
+ })
+ console.log(`${new Date().toLocaleString()}: [MuPiBox-Server] /api/playtime/extend +${minutes} min`)
+ res.status(200).json({ ok: true, addedMinutes: minutes })
+ } catch (err) {
+ console.error(`${new Date().toLocaleString()}: [MuPiBox-Server] /api/playtime/extend failed:`, err)
+ res.status(500).json({ error: 'internal error' })
+ }
+})
+
+// POST /api/playtime/release body: { minutes?: number }
+// Sets `playbackOverride.allowUntil = now + minutes*60_000`. While that timestamp
+// is in the future, all blocks are bypassed. Default 60 min if not specified.
+app.post('/api/playtime/release', async (req, res) => {
+ const minutes = req.body?.minutes !== undefined ? Number(req.body.minutes) : 60
+ if (!Number.isFinite(minutes) || minutes <= 0 || minutes > 1440) {
+ res.status(400).json({ error: 'minutes must be a positive number <= 1440' })
+ return
+ }
+ const until = Date.now() + minutes * 60_000
+ try {
+ await updateMupiboxConfig((cfg) => {
+ let ov = cfg.playbackOverride as Record | undefined
+ if (!ov || typeof ov !== 'object') {
+ ov = {}
+ cfg.playbackOverride = ov
+ }
+ ov.allowUntil = until
+ })
+ console.log(
+ `${new Date().toLocaleString()}: [MuPiBox-Server] /api/playtime/release for ${minutes} min (until ${new Date(until).toLocaleString()})`,
+ )
+ res.status(200).json({ ok: true, minutes, until })
+ } catch (err) {
+ console.error(`${new Date().toLocaleString()}: [MuPiBox-Server] /api/playtime/release failed:`, err)
+ res.status(500).json({ error: 'internal error' })
+ }
+})
+
+// POST /api/quiethours/now body: { minutes?: number }
+// Sets `playbackOverride.forceBlockUntil = now + minutes*60_000`. Forces playback
+// off immediately (kid sees the override overlay). Default 60 min.
+app.post('/api/quiethours/now', async (req, res) => {
+ const minutes = req.body?.minutes !== undefined ? Number(req.body.minutes) : 60
+ if (!Number.isFinite(minutes) || minutes <= 0 || minutes > 1440) {
+ res.status(400).json({ error: 'minutes must be a positive number <= 1440' })
+ return
+ }
+ const until = Date.now() + minutes * 60_000
+ try {
+ await updateMupiboxConfig((cfg) => {
+ let ov = cfg.playbackOverride as Record | undefined
+ if (!ov || typeof ov !== 'object') {
+ ov = {}
+ cfg.playbackOverride = ov
+ }
+ ov.forceBlockUntil = until
+ })
+ console.log(
+ `${new Date().toLocaleString()}: [MuPiBox-Server] /api/quiethours/now for ${minutes} min (until ${new Date(until).toLocaleString()})`,
+ )
+ res.status(200).json({ ok: true, minutes, until })
+ } catch (err) {
+ console.error(`${new Date().toLocaleString()}: [MuPiBox-Server] /api/quiethours/now failed:`, err)
+ res.status(500).json({ error: 'internal error' })
+ }
+})
+
app.get('/api/activeresume', (_req, res) => {
if (fs.existsSync(activeresumeFile)) {
jsonfile.readFile(activeresumeFile, (error, data) => {
diff --git a/src/backend-player/src/spotify-control.js b/src/backend-player/src/spotify-control.js
index bbb78c83..8a3ddf54 100644
--- a/src/backend-player/src/spotify-control.js
+++ b/src/backend-player/src/spotify-control.js
@@ -283,9 +283,40 @@ function readPlaytimeConfig() {
resetHour,
maxOverrunMinutes,
limitsMinutes: { ...PLAYTIME_DEFAULT_LIMITS, ...(raw.limitsMinutes || {}) },
+ todayBonus: raw.todayBonus || null,
}
}
+// Bonus minutes awarded by parent (via Telegram /extend or Admin) for today only.
+// If the stored date doesn't match the current logical day, the bonus is treated
+// as 0 — auto-resets at day rollover without needing to clear it explicitly.
+function getTodayBonusMinutes(cfg, todayDateStr) {
+ const b = cfg.todayBonus
+ if (!b || typeof b !== 'object') return 0
+ if (b.date !== todayDateStr) return 0
+ const m = Number(b.minutes)
+ if (!Number.isFinite(m) || m <= 0) return 0
+ return Math.min(m, 1440)
+}
+
+// Parent overrides via Telegram or admin endpoints.
+// allowUntil → bypass all blocks (state stays 'normal', no finalize calls)
+// forceBlockUntil → force playback off (state forced to 'blocked', stop() called)
+function readPlaybackOverrides() {
+ const raw = muPiBoxConfig?.playbackOverride || {}
+ const allowUntil = Number(raw.allowUntil) || 0
+ const forceBlockUntil = Number(raw.forceBlockUntil) || 0
+ return { allowUntil, forceBlockUntil }
+}
+
+function isAllowOverrideActive() {
+ return Date.now() < readPlaybackOverrides().allowUntil
+}
+
+function isForceBlockActive() {
+ return Date.now() < readPlaybackOverrides().forceBlockUntil
+}
+
// === Quiet Hours ===
const QUIET_DEFAULT_SCHEDULE = { mon: [], tue: [], wed: [], thu: [], fri: [], sat: [], sun: [] }
@@ -400,23 +431,37 @@ function loadPlaytimeCheckpoint() {
function writeCombinedWorking() {
const ptCfg = readPlaytimeConfig()
const qhCfg = readQuietHoursConfig()
+ const ovr = readPlaybackOverrides()
+ const now = Date.now()
+ const inForceBlock = now < ovr.forceBlockUntil
+ const inAllowOverride = now < ovr.allowUntil
- if (!ptCfg.enabled && !qhCfg.enabled) {
+ if (!ptCfg.enabled && !qhCfg.enabled && !inForceBlock && !inAllowOverride) {
fs.writeFile(PLAYTIME_WORKING_PATH, JSON.stringify({ enabled: false }), () => {})
return
}
- // Combined effective state across both sub-systems
+ // Effective state: forceBlock wins, then allowOverride forces normal,
+ // otherwise combine the two sub-systems naturally.
let state = 'normal'
- if (playtimeState.state === 'blocked' || quietHoursState.state === 'blocked') state = 'blocked'
- else if (playtimeState.state === 'grace' || quietHoursState.state === 'grace') state = 'grace'
-
- // blockSource: prefer 'quiet' over 'playtime' if both are restricting (more "explainable" to the kid)
let blockSource = null
- if (quietHoursState.state !== 'normal') blockSource = 'quiet'
- else if (playtimeState.state !== 'normal') blockSource = 'playtime'
+ if (inForceBlock) {
+ state = 'blocked'
+ blockSource = 'override'
+ } else if (!inAllowOverride) {
+ if (playtimeState.state === 'blocked' || quietHoursState.state === 'blocked') state = 'blocked'
+ else if (playtimeState.state === 'grace' || quietHoursState.state === 'grace') state = 'grace'
+ if (state !== 'normal') {
+ // Prefer 'quiet' over 'playtime' if both restrict — more explainable
+ if (quietHoursState.state !== 'normal') blockSource = 'quiet'
+ else if (playtimeState.state !== 'normal') blockSource = 'playtime'
+ }
+ }
+ // else: allowUntil-override active → state stays 'normal'
- const ptLimit = ptCfg.enabled ? (ptCfg.limitsMinutes[playtimeState.dayKey] ?? 60) : 0
+ const baseLimit = ptCfg.enabled ? (ptCfg.limitsMinutes[playtimeState.dayKey] ?? 60) : 0
+ const bonus = ptCfg.enabled ? getTodayBonusMinutes(ptCfg, playtimeState.date) : 0
+ const ptLimit = baseLimit + bonus
const ptGraceEndsInSeconds =
playtimeState.graceEndsAt !== null ? Math.max(0, Math.ceil((playtimeState.graceEndsAt - Date.now()) / 1000)) : 0
const qhGraceEndsInSeconds =
@@ -444,6 +489,10 @@ function writeCombinedWorking() {
...(quietHoursState.activeWindow?.label ? { label: quietHoursState.activeWindow.label } : {}),
graceEndsInSeconds: qhGraceEndsInSeconds,
},
+ override: {
+ allowUntil: ovr.allowUntil,
+ forceBlockUntil: ovr.forceBlockUntil,
+ },
}
fs.writeFile(PLAYTIME_WORKING_PATH, JSON.stringify(payload), () => {})
}
@@ -462,8 +511,13 @@ function writePlaytimeCheckpoint() {
}
// Used by the catch-all to decide whether to refuse new play/resume/skip commands.
-// True if EITHER playtime OR quiet hours is currently restricting playback.
+// Combines the natural state of both sub-systems with parent overrides:
+// - forceBlockUntil active → always blocked (highest priority)
+// - allowUntil active → never blocked (parent gave the green light)
+// - otherwise: blocked if playtime OR quiet hours says so
function isPlaybackBlocked() {
+ if (isForceBlockActive()) return true
+ if (isAllowOverrideActive()) return false
return (
playtimeState.state === 'grace' ||
playtimeState.state === 'blocked' ||
@@ -474,8 +528,10 @@ function isPlaybackBlocked() {
// Transition to fully-stopped state. Called from the tick on grace timeout, from the
// mplayer track-change/playlist-finish handlers, or directly when grace=0.
+// While allowUntil-override is active, transitions are suppressed — the parent has
+// explicitly green-lit playback for this window, so neither grace nor stop fire.
function finalizePlaytimeBlock(reason) {
- // console.log so it shows even when logLevel='error' (the default)
+ if (isAllowOverrideActive()) return
console.log(`${new Date().toLocaleString()}: [Playtime] Finalizing block (${reason})`)
playtimeState.state = 'blocked'
playtimeState.graceEndsAt = null
@@ -488,6 +544,7 @@ function finalizePlaytimeBlock(reason) {
}
function finalizeQuietHoursBlock(reason) {
+ if (isAllowOverrideActive()) return
console.log(`${new Date().toLocaleString()}: [QuietHours] Finalizing block (${reason})`)
quietHoursState.state = 'blocked'
quietHoursState.graceEndsAt = null
@@ -544,7 +601,10 @@ function playtimeTickStep() {
if (isActuallyPlaying()) {
playtimeState.usedSeconds++
}
- const limit = cfg.limitsMinutes[today.dayKey] ?? 60
+ // Effective limit = base + bonus (bonus auto-zeroes when its date doesn't match today)
+ const baseLimit = cfg.limitsMinutes[today.dayKey] ?? 60
+ const bonus = getTodayBonusMinutes(cfg, today.dateStr)
+ const limit = baseLimit + bonus
const limitSeconds = limit * 60
const limitReached = playtimeState.usedSeconds >= limitSeconds
if (limitReached) {
@@ -554,7 +614,7 @@ function playtimeTickStep() {
playtimeState.state = 'grace'
playtimeState.graceEndsAt = Date.now() + overrunMs
console.log(
- `${now.toLocaleString()}: [Playtime] Daily limit reached (${limit} min for ${today.dayKey}). Entering grace period (max ${cfg.maxOverrunMinutes} min until current track ends).`,
+ `${now.toLocaleString()}: [Playtime] Daily limit reached (${limit} min for ${today.dayKey}${bonus > 0 ? `, +${bonus} bonus` : ''}). Entering grace period (max ${cfg.maxOverrunMinutes} min until current track ends).`,
)
writePlaytimeCheckpoint()
} else {
@@ -564,8 +624,22 @@ function playtimeTickStep() {
if (playtimeState.graceEndsAt !== null && Date.now() >= playtimeState.graceEndsAt) {
finalizePlaytimeBlock(`grace period expired (${cfg.maxOverrunMinutes} min)`)
}
+ } else if (playtimeState.state === 'blocked') {
+ // Still blocked, but parent might have just added bonus — re-evaluate
+ if (playtimeState.usedSeconds < limitSeconds) {
+ playtimeState.state = 'normal'
+ console.log(
+ `${now.toLocaleString()}: [Playtime] Bonus applied (${bonus} min) — releasing block, ${Math.ceil((limitSeconds - playtimeState.usedSeconds) / 60)} min remaining.`,
+ )
+ }
}
- // else 'blocked': stay blocked
+ } else if (playtimeState.state !== 'normal') {
+ // Counter is below the limit (e.g. parent extended the limit) — release.
+ playtimeState.state = 'normal'
+ playtimeState.graceEndsAt = null
+ console.log(
+ `${now.toLocaleString()}: [Playtime] Released — usedSeconds (${playtimeState.usedSeconds}) below new limit (${limitSeconds})`,
+ )
}
if (
Date.now() - playtimeLastCheckpointAt >= PLAYTIME_CHECKPOINT_INTERVAL_MS &&
@@ -628,7 +702,33 @@ function quietHoursTickStep() {
}
}
+// Tracks whether forceBlock was active on the previous tick so we only call stop()
+// once on entry (not every second while it's still in effect).
+let forceBlockWasActive = false
+
function combinedTick() {
+ if (isForceBlockActive()) {
+ if (!forceBlockWasActive) {
+ const until = readPlaybackOverrides().forceBlockUntil
+ console.log(
+ `${new Date().toLocaleString()}: [Override] Force-block engaged until ${new Date(until).toLocaleString()}`,
+ )
+ try {
+ stop()
+ } catch (e) {
+ console.error(`${new Date().toLocaleString()}: [Override] Error stopping playback:`, e)
+ }
+ }
+ forceBlockWasActive = true
+ // Skip natural ticks: we don't want playtimeState/quietHoursState mutating
+ // while force-block is in effect (it would mask the actual reason in /status).
+ writeCombinedWorking()
+ return
+ }
+ if (forceBlockWasActive) {
+ console.log(`${new Date().toLocaleString()}: [Override] Force-block ended.`)
+ forceBlockWasActive = false
+ }
playtimeTickStep()
quietHoursTickStep()
writeCombinedWorking()
diff --git a/src/frontend-box/src/app/playtime.model.ts b/src/frontend-box/src/app/playtime.model.ts
index 45cb16f4..baf61181 100644
--- a/src/frontend-box/src/app/playtime.model.ts
+++ b/src/frontend-box/src/app/playtime.model.ts
@@ -2,7 +2,7 @@ export type PlaytimeDayKey = 'mon' | 'tue' | 'wed' | 'thu' | 'fri' | 'sat' | 'su
export type PlaytimePlayState = 'normal' | 'grace' | 'blocked'
-export type PlaybackBlockSource = 'playtime' | 'quiet'
+export type PlaybackBlockSource = 'playtime' | 'quiet' | 'override'
export type PlaytimeStatus = PlaytimeStatusActive | PlaytimeStatusDisabled
@@ -36,4 +36,8 @@ export interface PlaytimeStatusActive {
blockSource: PlaybackBlockSource | null
playtime: PlaytimeSubStatus
quiet: QuietHoursSubStatus
+ override?: {
+ allowUntil: number
+ forceBlockUntil: number
+ }
}
From 61bd953aa0c047eb48b8f2207e1915282169414c Mon Sep 17 00:00:00 2001
From: Waldemar Berg
Date: Thu, 7 May 2026 14:17:02 +0200
Subject: [PATCH 19/20] telegram: multi-chat support, /limit set, cap-reached
notifications, admin-UI editor
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Three coordinated Telegram improvements that all share the chatId
data-format change.
T1 — multi-chat-auth + admin-UI editor
- telegram.chatId in mupiboxconfig.json now accepts an array of
{id, label?} objects in addition to the legacy single string. New
shared helper scripts/telegram/telegram_chats.py centralises the
normalisation (legacy string, single number, array of strings, or
the new object array), with send_to_all() / photo_to_all() helpers.
All ten on-box telegram_*.py scripts loop over every configured
chat, so notifications reach the family group + each parent's DM.
- telegram_receiver.py: ALLOWED_CHAT_IDS is now a set built from the
same normalisation, so any configured chat can issue parent-control
commands. The receiver's "reply to whoever asked" semantics are
unchanged.
- spotify-control.js: hasConfiguredTelegram() helper replaces the
eight scattered (active && token.length>1 && chatId.length>1)
checks. Necessary because chatId.length on the new array form means
"number of entries", not "string length", and could falsely
short-circuit notifications.
- AdminInterface smart.php: replaces the single Chat ID input with a
multi-row editor (id + optional label per row, add/remove buttons),
parallel-array form fields parsed back into the object array on
save. "Generate Telegram Chat ID" now appends the freshly detected
chat to the list instead of clobbering it. Legacy single-string
values are migrated to the new format on first save.
T2 — /limit set bot command
- New /api/playtime/limit endpoint mutates
playtimeLimit.limitsMinutes. via updateMupiboxConfig — same
pattern as /extend, /release, /quietnow. Live-reload picks it up.
- Receiver gets a /limit command with usage hints; /command help
text updated.
T3 — cap-reached push notifications
- finalizePlaytimeBlock fires "Hörzeit aufgebraucht heute" via
telegram_send_message.py.
- finalizeQuietHoursBlock fires "Ruhezeit gestartet" (with the
window's label appended if set, e.g. "Ruhezeit gestartet:
Hausaufgaben").
- Both go through the shared sender, so every configured parent
chat is notified.
---
AdminInterface/www/smart.php | 108 +++++++++++++++--
scripts/telegram/telegram_Track_Local.py | 18 +--
scripts/telegram/telegram_Track_RSS_Radio.py | 18 +--
scripts/telegram/telegram_Track_Spotify.py | 23 ++--
scripts/telegram/telegram_chats.py | 55 +++++++++
scripts/telegram/telegram_end_publish.py | 12 +-
scripts/telegram/telegram_notify_screen.py | 13 ++-
scripts/telegram/telegram_playing.py | 17 ++-
scripts/telegram/telegram_receiver.py | 58 +++++++++-
scripts/telegram/telegram_send_message.py | 14 +--
scripts/telegram/telegram_shutdown.py | 15 ++-
scripts/telegram/telegram_start.py | 12 +-
scripts/telegram/telegram_stop.py | 20 ++--
src/backend-api/src/server.ts | 39 +++++++
src/backend-player/src/spotify-control.js | 116 ++++++++-----------
15 files changed, 374 insertions(+), 164 deletions(-)
create mode 100644 scripts/telegram/telegram_chats.py
diff --git a/AdminInterface/www/smart.php b/AdminInterface/www/smart.php
index ff5ddd21..2aa567d5 100644
--- a/AdminInterface/www/smart.php
+++ b/AdminInterface/www/smart.php
@@ -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."
Detected chat id ".$generated_id." added to the list.
";
+ } else {
+ $CHANGE_TXT=$CHANGE_TXT."
Telegram chat id detection: ".($generated_id === "" || $generated_id === "null" ? "no chat detected — write to your bot first" : "chat id already known")."
";
}
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'])
{
@@ -378,14 +420,62 @@
-
-
- "/>
-
Please enter your telegram ChatId.
+
+
+ $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 '
';
+ echo '';
+ echo '';
+ echo '';
+ echo '
';
+ }
+?>
+
+
+
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.
+
+
diff --git a/scripts/telegram/telegram_Track_Local.py b/scripts/telegram/telegram_Track_Local.py
index a1bca8e5..6d82cf4b 100644
--- a/scripts/telegram/telegram_Track_Local.py
+++ b/scripts/telegram/telegram_Track_Local.py
@@ -1,11 +1,10 @@
#!/usr/bin/python3
-import sys
-import time
import telepot
import json
import requests
import subprocess
+from telegram_chats import normalize_chat_ids, send_to_all, photo_to_all
with open("/etc/mupibox/mupiboxconfig.json") as file:
config = json.load(file)
@@ -13,15 +12,16 @@
if not config['telegram']['active']:
quit()
-url = 'http://127.0.0.1:5005/local'
-local = requests.get(url).json()
+chat_ids = normalize_chat_ids(config['telegram'].get('chatId'))
+if not chat_ids:
+ quit()
+
+local = requests.get('http://127.0.0.1:5005/local').json()
-TOKEN = config['telegram']['token']
-bot = telepot.Bot(TOKEN)
-chat_id = config['telegram']['chatId']
+bot = telepot.Bot(config['telegram']['token'])
msg = local['album'] + "\n" + local['currentTrackname'] + "\nTrack: " + str(local['currentTracknr']) + "/" + str(local['totalTracks'])
-bot.sendMessage(chat_id, msg)
+send_to_all(bot, msg, chat_ids)
subprocess.run(["sudo", "rm", "/tmp/telegram_screen.png"])
subprocess.run(["sudo", "-H", "-u", "dietpi", "bash", "-c", "DISPLAY=:0 scrot /tmp/telegram_screen.png"])
-bot.sendPhoto(chat_id, open('/tmp/telegram_screen.png', 'rb'))
+photo_to_all(bot, '/tmp/telegram_screen.png', chat_ids)
diff --git a/scripts/telegram/telegram_Track_RSS_Radio.py b/scripts/telegram/telegram_Track_RSS_Radio.py
index 42791e26..5d03e3f4 100644
--- a/scripts/telegram/telegram_Track_RSS_Radio.py
+++ b/scripts/telegram/telegram_Track_RSS_Radio.py
@@ -1,11 +1,10 @@
#!/usr/bin/python3
-import sys
-import time
import telepot
import json
import requests
import subprocess
+from telegram_chats import normalize_chat_ids, send_to_all, photo_to_all
with open("/etc/mupibox/mupiboxconfig.json") as file:
config = json.load(file)
@@ -13,15 +12,16 @@
if not config['telegram']['active']:
quit()
-url = 'http://127.0.0.1:5005/local'
-local = requests.get(url).json()
+chat_ids = normalize_chat_ids(config['telegram'].get('chatId'))
+if not chat_ids:
+ quit()
+
+local = requests.get('http://127.0.0.1:5005/local').json()
-TOKEN = config['telegram']['token']
-bot = telepot.Bot(TOKEN)
-chat_id = config['telegram']['chatId']
+bot = telepot.Bot(config['telegram']['token'])
msg = local['album'] + "\n" + local['currentTrackname']
-bot.sendMessage(chat_id, msg)
+send_to_all(bot, msg, chat_ids)
subprocess.run(["sudo", "rm", "/tmp/telegram_screen.png"])
subprocess.run(["sudo", "-H", "-u", "dietpi", "bash", "-c", "DISPLAY=:0 scrot /tmp/telegram_screen.png"])
-bot.sendPhoto(chat_id, open('/tmp/telegram_screen.png', 'rb'))
+photo_to_all(bot, '/tmp/telegram_screen.png', chat_ids)
diff --git a/scripts/telegram/telegram_Track_Spotify.py b/scripts/telegram/telegram_Track_Spotify.py
index 00be9552..662eb835 100644
--- a/scripts/telegram/telegram_Track_Spotify.py
+++ b/scripts/telegram/telegram_Track_Spotify.py
@@ -1,12 +1,12 @@
#!/usr/bin/python3
import sys
-import time
import os
import telepot
import json
import requests
import subprocess
+from telegram_chats import normalize_chat_ids, send_to_all, photo_to_all
with open("/etc/mupibox/mupiboxconfig.json") as file:
config = json.load(file)
@@ -14,29 +14,28 @@
if not config['telegram']['active']:
quit()
-url = 'http://127.0.0.1:5005/state'
-state = requests.get(url).json()
+chat_ids = normalize_chat_ids(config['telegram'].get('chatId'))
+if not chat_ids:
+ quit()
-urls = 'http://127.0.0.1:5005/episode'
+state = requests.get('http://127.0.0.1:5005/state').json()
-TOKEN = config['telegram']['token']
-bot = telepot.Bot(TOKEN)
-chat_id = config['telegram']['chatId']
+bot = telepot.Bot(config['telegram']['token'])
player_event = os.environ.get('PLAYER_EVENT')
POSITION_MS = os.environ.get('POSITION_MS')
if player_event == "playing" and POSITION_MS == "0":
if state['currently_playing_type'] == 'episode':
- episode = requests.get(urls).json()
+ episode = requests.get('http://127.0.0.1:5005/episode').json()
msg = episode['show']['name'] + "\n" + episode['name']
- bot.sendMessage(chat_id, msg)
+ send_to_all(bot, msg, chat_ids)
subprocess.run(["sudo", "rm", "/tmp/telegram_screen.png"])
subprocess.run(["sudo", "-H", "-u", "dietpi", "bash", "-c", "DISPLAY=:0 scrot /tmp/telegram_screen.png"])
- bot.sendPhoto(chat_id, open('/tmp/telegram_screen.png', 'rb'))
+ photo_to_all(bot, '/tmp/telegram_screen.png', chat_ids)
sys.exit()
msg = state['item']['album']['name'] + "\n" + state['item']['name'] + "\nTrack: " + str(state['item']['track_number']) + "/" + str(state['item']['album']['total_tracks'])
- bot.sendMessage(chat_id, msg)
+ send_to_all(bot, msg, chat_ids)
subprocess.run(["sudo", "rm", "/tmp/telegram_screen.png"])
subprocess.run(["sudo", "-H", "-u", "dietpi", "bash", "-c", "DISPLAY=:0 scrot /tmp/telegram_screen.png"])
- bot.sendPhoto(chat_id, open('/tmp/telegram_screen.png', 'rb'))
+ photo_to_all(bot, '/tmp/telegram_screen.png', chat_ids)
diff --git a/scripts/telegram/telegram_chats.py b/scripts/telegram/telegram_chats.py
new file mode 100644
index 00000000..e308be91
--- /dev/null
+++ b/scripts/telegram/telegram_chats.py
@@ -0,0 +1,55 @@
+#!/usr/bin/python3
+"""Shared helpers for telegram_*.py scripts.
+
+The on-box scripts live in /usr/local/bin/mupibox/ and are invoked with
+absolute paths; Python adds the script's directory to sys.path, so a plain
+`from telegram_chats import ...` works at runtime.
+"""
+
+
+def normalize_chat_ids(value):
+ """Return a list of stringified Telegram chat IDs.
+
+ `telegram.chatId` in mupiboxconfig.json may be:
+ - empty / None / empty string / empty array → []
+ - a single string or number (legacy single-chat) → [str(value)]
+ - an array of strings/numbers → each stringified
+ - an array of {id, label?} objects (current admin-UI format) → use .id
+ """
+ if not value:
+ return []
+ if isinstance(value, (str, int, float)):
+ s = str(value).strip()
+ return [s] if s else []
+ if isinstance(value, list):
+ out = []
+ for item in value:
+ if isinstance(item, (str, int, float)):
+ s = str(item).strip()
+ if s:
+ out.append(s)
+ elif isinstance(item, dict):
+ cid = str(item.get('id', '')).strip()
+ if cid:
+ out.append(cid)
+ return out
+ return []
+
+
+def send_to_all(bot, message, chat_ids, **kwargs):
+ """sendMessage to each chat_id, swallow per-chat errors."""
+ for cid in chat_ids:
+ try:
+ bot.sendMessage(cid, message, **kwargs)
+ except Exception as e:
+ print(f'Failed to send to {cid}: {e}')
+
+
+def photo_to_all(bot, photo_path, chat_ids):
+ """sendPhoto to each chat_id, swallow per-chat errors."""
+ for cid in chat_ids:
+ try:
+ with open(photo_path, 'rb') as f:
+ bot.sendPhoto(cid, f)
+ except Exception as e:
+ print(f'Failed to send photo to {cid}: {e}')
diff --git a/scripts/telegram/telegram_end_publish.py b/scripts/telegram/telegram_end_publish.py
index a9ac4bf1..4b8cf5cf 100644
--- a/scripts/telegram/telegram_end_publish.py
+++ b/scripts/telegram/telegram_end_publish.py
@@ -1,15 +1,15 @@
#!/usr/bin/python3
-import sys
-import time
import telepot
import json
+from telegram_chats import normalize_chat_ids, send_to_all
with open("/etc/mupibox/mupiboxconfig.json") as file:
config = json.load(file)
-TOKEN = config['telegram']['token']
-bot = telepot.Bot(TOKEN)
-chat_id = config['telegram']['chatId']
+chat_ids = normalize_chat_ids(config['telegram'].get('chatId'))
+if not chat_ids:
+ quit()
-bot.sendMessage(chat_id, 'Sending data to telegram has been disabled!')
\ No newline at end of file
+bot = telepot.Bot(config['telegram']['token'])
+send_to_all(bot, 'Sending data to telegram has been disabled!', chat_ids)
diff --git a/scripts/telegram/telegram_notify_screen.py b/scripts/telegram/telegram_notify_screen.py
index cf51e274..1e0d918d 100644
--- a/scripts/telegram/telegram_notify_screen.py
+++ b/scripts/telegram/telegram_notify_screen.py
@@ -4,6 +4,7 @@
import subprocess
import json
import telepot
+from telegram_chats import normalize_chat_ids, send_to_all, photo_to_all
with open("/etc/mupibox/mupiboxconfig.json") as file:
config = json.load(file)
@@ -11,14 +12,16 @@
if not config['telegram']['active']:
sys.exit()
-TOKEN = config['telegram']['token']
-bot = telepot.Bot(TOKEN)
-chat_id = config['telegram']['chatId']
+chat_ids = normalize_chat_ids(config['telegram'].get('chatId'))
+if not chat_ids:
+ sys.exit()
+
+bot = telepot.Bot(config['telegram']['token'])
if len(sys.argv) > 1:
msg = "\n".join(sys.argv[1:])
- bot.sendMessage(chat_id, msg)
+ send_to_all(bot, msg, chat_ids)
subprocess.run(["sudo", "rm", "-f", "/tmp/telegram_screen.png"])
subprocess.run(["sudo", "-H", "-u", "dietpi", "bash", "-c", "DISPLAY=:0 scrot /tmp/telegram_screen.png"])
-bot.sendPhoto(chat_id, open('/tmp/telegram_screen.png', 'rb'))
+photo_to_all(bot, '/tmp/telegram_screen.png', chat_ids)
diff --git a/scripts/telegram/telegram_playing.py b/scripts/telegram/telegram_playing.py
index af50b2c6..1a42b662 100644
--- a/scripts/telegram/telegram_playing.py
+++ b/scripts/telegram/telegram_playing.py
@@ -1,10 +1,9 @@
#!/usr/bin/python3
-import sys
-import time
import telepot
import json
import requests
+from telegram_chats import normalize_chat_ids, send_to_all
with open("/etc/mupibox/mupiboxconfig.json") as file:
config = json.load(file)
@@ -12,13 +11,11 @@
if not config['telegram']['active']:
quit()
-url = 'http://127.0.0.1:5005/local'
-state = requests.get(url).json()
-
-TOKEN = config['telegram']['token']
-bot = telepot.Bot(TOKEN)
-chat_id = config['telegram']['chatId']
+chat_ids = normalize_chat_ids(config['telegram'].get('chatId'))
+if not chat_ids:
+ quit()
-msg = config['mupibox']['host'] + " is playing"
+requests.get('http://127.0.0.1:5005/local')
-bot.sendMessage(chat_id, msg)
+bot = telepot.Bot(config['telegram']['token'])
+send_to_all(bot, config['mupibox']['host'] + " is playing", chat_ids)
diff --git a/scripts/telegram/telegram_receiver.py b/scripts/telegram/telegram_receiver.py
index 20793227..b404a45c 100644
--- a/scripts/telegram/telegram_receiver.py
+++ b/scripts/telegram/telegram_receiver.py
@@ -15,11 +15,36 @@
if not config['telegram']['active']:
quit()
-# Authorization: only respond to messages from the configured chat. Without this
+PLAYTIME_DAY_KEYS = {'mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'}
+
+def _normalize_chat_ids(value):
+ """telegram.chatId may be a single string/number (legacy), an array of
+ strings/numbers, or an array of {id, label?} objects (current format).
+ Return a list of stringified IDs."""
+ if not value:
+ return []
+ if isinstance(value, (str, int, float)):
+ s = str(value).strip()
+ return [s] if s else []
+ if isinstance(value, list):
+ out = []
+ for item in value:
+ if isinstance(item, (str, int, float)):
+ s = str(item).strip()
+ if s:
+ out.append(s)
+ elif isinstance(item, dict):
+ cid = str(item.get('id', '')).strip()
+ if cid:
+ out.append(cid)
+ return out
+ return []
+
+# Authorization: only respond to messages from configured chats. Without this
# anyone who learns the bot username could send /shutdown / /quietnow / etc.
-# An empty chatId is treated as "deny all" — the user has to configure one for
+# An empty list is treated as "deny all" — the user has to configure one for
# outbound notifications anyway.
-ALLOWED_CHAT_ID = str(config['telegram'].get('chatId', '')).strip()
+ALLOWED_CHAT_IDS = set(_normalize_chat_ids(config['telegram'].get('chatId')))
# Backend-API base URL on the same host. Used for parent-control commands
# (extend / release / quietnow / status). The player listens for config
@@ -27,10 +52,10 @@
API_BASE = 'http://localhost:8200/api'
def is_authorized(chat_id):
- if not ALLOWED_CHAT_ID:
+ if not ALLOWED_CHAT_IDS:
print('Refusing message: no chatId configured in mupiboxconfig.json')
return False
- return str(chat_id) == ALLOWED_CHAT_ID
+ return str(chat_id) in ALLOWED_CHAT_IDS
def fmt_minutes(seconds):
if seconds <= 0:
@@ -164,6 +189,27 @@ def on_chat_message(msg):
bot.sendMessage(chat_id, f'⛔ Sofort-Stopp für {mins} min aktiviert.')
else:
bot.sendMessage(chat_id, f'Fehler: {status_code} {body}')
+ elif command[:6] == '/limit':
+ # Usage: /limit set (day = mon..sun, minutes = 0..1440)
+ # Mutates playtimeLimit.limitsMinutes. in mupiboxconfig.json.
+ # The player picks up the change via fs.watch within ~50 ms.
+ parts = command.split()
+ if len(parts) >= 4 and parts[1] == 'set':
+ day = parts[2].lower()
+ try:
+ mins = int(parts[3])
+ except (ValueError, TypeError):
+ mins = -1
+ if day not in PLAYTIME_DAY_KEYS or mins < 0 or mins > 1440:
+ bot.sendMessage(chat_id, 'Nutzung: /limit set ')
+ else:
+ status_code, body = call_api_post('/playtime/limit', {'day': day, 'minutes': mins})
+ if status_code == 200:
+ bot.sendMessage(chat_id, f'✅ Limit für {day} auf {mins} min gesetzt.')
+ else:
+ bot.sendMessage(chat_id, f'Fehler: {status_code} {body}')
+ else:
+ bot.sendMessage(chat_id, 'Nutzung: /limit set ')
elif command == '/help':
markup = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="Status",callback_data='status'), InlineKeyboardButton(text="Current Screen",callback_data='screen')],
@@ -178,7 +224,7 @@ def on_chat_message(msg):
global message_with_inline_keyboard
message_with_inline_keyboard = bot.sendMessage(chat_id, 'Possible commands:',reply_markup = markup)
elif command == '/command':
- bot.sendMessage(chat_id, "Possible commands:\n\n/help\nshows the inline keyboard\n\n/status\nshow current playtime + quiet hours status\n\n/extend[minutes, default 30]\nadd bonus minutes to today's playtime cap\n\n/release[minutes, default 60]\nbypass all blocks for N minutes\n\n/quietnow[minutes, default 60]\nforce-block playback for N minutes\n\n/reboot\n/shutdown\n/screen\n/sleep[minutes]\n/vol[0-100]\n/media\n/finishalbum", parse_mode='HTML')
+ bot.sendMessage(chat_id, "Possible commands:\n\n/help\nshows the inline keyboard\n\n/status\nshow current playtime + quiet hours status\n\n/extend[minutes, default 30]\nadd bonus minutes to today's playtime cap\n\n/release[minutes, default 60]\nbypass all blocks for N minutes\n\n/quietnow[minutes, default 60]\nforce-block playback for N minutes\n\n/limit set<day> <minutes>\nset the playtime limit for one weekday (mon..sun, 0..1440)\n\n/reboot\n/shutdown\n/screen\n/sleep[minutes]\n/vol[0-100]\n/media\n/finishalbum", parse_mode='HTML')
elif command == '/media':
bot.sendMessage(chat_id, "Starting media data update... This take a while, please wait for complete message")
subprocess.run(["sudo", "/usr/local/bin/mupibox/./m3u_generator.sh"])
diff --git a/scripts/telegram/telegram_send_message.py b/scripts/telegram/telegram_send_message.py
index 72646d23..4ecc5f50 100644
--- a/scripts/telegram/telegram_send_message.py
+++ b/scripts/telegram/telegram_send_message.py
@@ -1,9 +1,9 @@
#!/usr/bin/python3
import sys
-import time
import telepot
import json
+from telegram_chats import normalize_chat_ids, send_to_all
with open("/etc/mupibox/mupiboxconfig.json") as file:
config = json.load(file)
@@ -11,15 +11,15 @@
if not config['telegram']['active']:
quit()
-TOKEN = config['telegram']['token']
-bot = telepot.Bot(TOKEN)
-chat_id = config['telegram']['chatId']
+chat_ids = normalize_chat_ids(config['telegram'].get('chatId'))
+if not chat_ids:
+ quit()
-bot.sendMessage(chat_id, sys.argv[1])
+bot = telepot.Bot(config['telegram']['token'])
+send_to_all(bot, sys.argv[1], chat_ids)
if config['mupihat']['hat_active']:
with open("/tmp/mupihat.json") as file:
mupihat = json.load(file)
-
if mupihat['BatteryConnected']:
- bot.sendMessage(chat_id, 'The MupiBox battery is at '+mupihat['Bat_SOC'])
\ No newline at end of file
+ send_to_all(bot, 'The MupiBox battery is at ' + mupihat['Bat_SOC'], chat_ids)
diff --git a/scripts/telegram/telegram_shutdown.py b/scripts/telegram/telegram_shutdown.py
index 1e062fcf..0a3ba64e 100644
--- a/scripts/telegram/telegram_shutdown.py
+++ b/scripts/telegram/telegram_shutdown.py
@@ -1,9 +1,8 @@
#!/usr/bin/python3
-import sys
-import time
import telepot
import json
+from telegram_chats import normalize_chat_ids, send_to_all
with open("/etc/mupibox/mupiboxconfig.json") as file:
config = json.load(file)
@@ -11,15 +10,15 @@
if not config['telegram']['active']:
quit()
-TOKEN = config['telegram']['token']
-bot = telepot.Bot(TOKEN)
-chat_id = config['telegram']['chatId']
+chat_ids = normalize_chat_ids(config['telegram'].get('chatId'))
+if not chat_ids:
+ quit()
-bot.sendMessage(chat_id, 'MuPiBox is shuting down!')
+bot = telepot.Bot(config['telegram']['token'])
+send_to_all(bot, 'MuPiBox is shuting down!', chat_ids)
if config['mupihat']['hat_active']:
with open("/tmp/mupihat.json") as file:
mupihat = json.load(file)
-
if mupihat['BatteryConnected']:
- bot.sendMessage(chat_id, 'The MupiBox battery is at '+mupihat['Bat_SOC'])
\ No newline at end of file
+ send_to_all(bot, 'The MupiBox battery is at ' + mupihat['Bat_SOC'], chat_ids)
diff --git a/scripts/telegram/telegram_start.py b/scripts/telegram/telegram_start.py
index bfac4cf6..1ffa5619 100644
--- a/scripts/telegram/telegram_start.py
+++ b/scripts/telegram/telegram_start.py
@@ -1,9 +1,8 @@
#!/usr/bin/python3
-import sys
-import time
import telepot
import json
+from telegram_chats import normalize_chat_ids, send_to_all
with open("/etc/mupibox/mupiboxconfig.json") as file:
config = json.load(file)
@@ -11,8 +10,9 @@
if not config['telegram']['active']:
quit()
-TOKEN = config['telegram']['token']
-bot = telepot.Bot(TOKEN)
-chat_id = config['telegram']['chatId']
+chat_ids = normalize_chat_ids(config['telegram'].get('chatId'))
+if not chat_ids:
+ quit()
-bot.sendMessage(chat_id, 'MuPiBox is online!')
\ No newline at end of file
+bot = telepot.Bot(config['telegram']['token'])
+send_to_all(bot, 'MuPiBox is online!', chat_ids)
diff --git a/scripts/telegram/telegram_stop.py b/scripts/telegram/telegram_stop.py
index 00764bfe..4ab99f53 100644
--- a/scripts/telegram/telegram_stop.py
+++ b/scripts/telegram/telegram_stop.py
@@ -1,10 +1,9 @@
#!/usr/bin/python3
-import sys
-import time
import telepot
import json
import requests
+from telegram_chats import normalize_chat_ids, send_to_all
with open("/etc/mupibox/mupiboxconfig.json") as file:
config = json.load(file)
@@ -12,13 +11,14 @@
if not config['telegram']['active']:
quit()
-url = 'http://127.0.0.1:5005/local'
-state = requests.get(url).json()
-
-TOKEN = config['telegram']['token']
-bot = telepot.Bot(TOKEN)
-chat_id = config['telegram']['chatId']
+chat_ids = normalize_chat_ids(config['telegram'].get('chatId'))
+if not chat_ids:
+ quit()
-msg = config['mupibox']['host'] + " stop playing"
+# Touching /local just to keep the existing "is the player alive" probe — the
+# response value isn't used here, the side effect is that we fail fast if the
+# backend-player isn't reachable.
+requests.get('http://127.0.0.1:5005/local')
-bot.sendMessage(chat_id, msg)
+bot = telepot.Bot(config['telegram']['token'])
+send_to_all(bot, config['mupibox']['host'] + " stop playing", chat_ids)
diff --git a/src/backend-api/src/server.ts b/src/backend-api/src/server.ts
index b4ceef7d..00ef8377 100644
--- a/src/backend-api/src/server.ts
+++ b/src/backend-api/src/server.ts
@@ -280,6 +280,45 @@ app.post('/api/playtime/release', async (req, res) => {
}
})
+// POST /api/playtime/limit body: { day: 'mon'|...|'sun', minutes: number }
+// Sets the daily playtime cap for one weekday in mupiboxconfig.json. Used by
+// the Telegram /limit set bot command so parents can adjust a single day
+// without opening the admin UI. Live-reload in the player picks the change up
+// within ~50 ms; no restart needed.
+const PLAYTIME_DAY_KEYS = new Set(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'])
+app.post('/api/playtime/limit', async (req, res) => {
+ const day = String(req.body?.day || '').toLowerCase()
+ const minutes = Number(req.body?.minutes)
+ if (!PLAYTIME_DAY_KEYS.has(day)) {
+ res.status(400).json({ error: 'day must be one of mon|tue|wed|thu|fri|sat|sun' })
+ return
+ }
+ if (!Number.isFinite(minutes) || minutes < 0 || minutes > 1440) {
+ res.status(400).json({ error: 'minutes must be in [0, 1440]' })
+ return
+ }
+ try {
+ await updateMupiboxConfig((cfg) => {
+ let pl = cfg.playtimeLimit as Record | undefined
+ if (!pl || typeof pl !== 'object') {
+ pl = {}
+ cfg.playtimeLimit = pl
+ }
+ let limits = pl.limitsMinutes as Record | undefined
+ if (!limits || typeof limits !== 'object') {
+ limits = {}
+ pl.limitsMinutes = limits
+ }
+ limits[day] = minutes
+ })
+ console.log(`${new Date().toLocaleString()}: [MuPiBox-Server] /api/playtime/limit ${day}=${minutes} min`)
+ res.status(200).json({ ok: true, day, minutes })
+ } catch (err) {
+ console.error(`${new Date().toLocaleString()}: [MuPiBox-Server] /api/playtime/limit failed:`, err)
+ res.status(500).json({ error: 'internal error' })
+ }
+})
+
// POST /api/quiethours/now body: { minutes?: number }
// Sets `playbackOverride.forceBlockUntil = now + minutes*60_000`. Forces playback
// off immediately (kid sees the override overlay). Default 60 min.
diff --git a/src/backend-player/src/spotify-control.js b/src/backend-player/src/spotify-control.js
index 8a3ddf54..4a8fb7fb 100644
--- a/src/backend-player/src/spotify-control.js
+++ b/src/backend-player/src/spotify-control.js
@@ -77,6 +77,29 @@ function setupMupiBoxConfigWatch() {
}
setupMupiBoxConfigWatch()
+// Returns true iff the Telegram integration is fully configured (active flag,
+// non-empty token, at least one chat id). Replaces the chatId.length > 1 +
+// token.length > 1 + active checks scattered throughout the file. Necessary
+// because chatId can now be a single string (legacy), or an array of strings,
+// or an array of {id, label?} objects (new admin-UI format).
+function hasConfiguredTelegram() {
+ const t = muPiBoxConfig?.telegram
+ if (!t || t.active !== true) return false
+ if (!t.token || String(t.token).length <= 1) return false
+ const chats = t.chatId
+ if (typeof chats === 'string') return chats.length > 1
+ if (typeof chats === 'number') return true
+ if (Array.isArray(chats)) {
+ return chats.some((c) => {
+ if (typeof c === 'string') return c.length > 1
+ if (typeof c === 'number') return true
+ if (c && typeof c === 'object') return c.id != null && String(c.id).length > 1
+ return false
+ })
+ }
+ return false
+}
+
const config = require(`${configBasePath}/config.json`)
const log = require('console-log-level')({ level: config.server.logLevel })
@@ -160,21 +183,9 @@ player.on('path', (val) => {
player.on('track-change', () => player.getProps(['path']))
player.on('track-change', () => {
- if (
- muPiBoxConfig.telegram.active &&
- //network.onlinestate === 'online' &&
- muPiBoxConfig.telegram.token.length > 1 &&
- muPiBoxConfig.telegram.chatId.length > 1 &&
- (currentMeta.currentType === 'rss' || currentMeta.currentType === 'radio')
- )
+ if (hasConfiguredTelegram() && (currentMeta.currentType === 'rss' || currentMeta.currentType === 'radio'))
cmdCall('/usr/bin/python3 /usr/local/bin/mupibox/telegram_Track_RSS_Radio.py')
- if (
- muPiBoxConfig.telegram.active &&
- //network.onlinestate === 'online' &&
- muPiBoxConfig.telegram.token.length > 1 &&
- muPiBoxConfig.telegram.chatId.length > 1 &&
- currentMeta.currentType === 'local'
- )
+ if (hasConfiguredTelegram() && currentMeta.currentType === 'local')
cmdCall('/usr/bin/python3 /usr/local/bin/mupibox/telegram_Track_Local.py')
})
@@ -541,13 +552,24 @@ function finalizePlaytimeBlock(reason) {
console.error(`${new Date().toLocaleString()}: [Playtime] Error stopping playback:`, e)
}
writePlaytimeCheckpoint()
+ // Notify parents that today's listening time is up. telegram_send_message.py
+ // loops over all configured chatIds, so both Family group and individual DMs
+ // receive the message.
+ if (hasConfiguredTelegram()) {
+ cmdCall('/usr/bin/python3 /usr/local/bin/mupibox/telegram_send_message.py "Hörzeit aufgebraucht heute"')
+ }
}
function finalizeQuietHoursBlock(reason) {
if (isAllowOverrideActive()) return
console.log(`${new Date().toLocaleString()}: [QuietHours] Finalizing block (${reason})`)
+ const label = quietHoursState.activeWindow?.label
quietHoursState.state = 'blocked'
quietHoursState.graceEndsAt = null
+ if (hasConfiguredTelegram()) {
+ const msg = label ? `Ruhezeit gestartet: ${label}` : 'Ruhezeit gestartet'
+ cmdCall(`/usr/bin/python3 /usr/local/bin/mupibox/telegram_send_message.py "${msg.replace(/"/g, '\\"')}"`)
+ }
try {
stop()
} catch (e) {
@@ -948,12 +970,7 @@ function transferPlaybackToActiveDevice() {
}
function pause() {
- if (
- muPiBoxConfig.telegram.active &&
- //network.onlinestate === 'online' &&
- muPiBoxConfig.telegram.token.length > 1 &&
- muPiBoxConfig.telegram.chatId.length > 1
- )
+ if (hasConfiguredTelegram())
cmdCall('/usr/bin/python3 /usr/local/bin/mupibox/telegram_send_message.py "Pause"')
currentMeta.pause = true
if (currentMeta.currentPlayer === 'spotify') {
@@ -980,12 +997,7 @@ function pause() {
}
function stop() {
- if (
- muPiBoxConfig.telegram.active &&
- //network.onlinestate === 'online' &&
- muPiBoxConfig.telegram.token.length > 1 &&
- muPiBoxConfig.telegram.chatId.length > 1
- )
+ if (hasConfiguredTelegram())
cmdCall('/usr/bin/python3 /usr/local/bin/mupibox/telegram_send_message.py "Stop"')
if (currentMeta.currentPlayer === 'spotify') {
spotifyApi.pause().then(
@@ -1039,26 +1051,16 @@ function play() {
handleSpotifyError(err, 'play')
},
)
- if (
- muPiBoxConfig.telegram.active &&
- //network.onlinestate === 'online' &&
- muPiBoxConfig.telegram.token.length > 1 &&
- muPiBoxConfig.telegram.chatId.length > 1
- )
+ if (hasConfiguredTelegram())
cmdCall('/usr/bin/python3 /usr/local/bin/mupibox/telegram_send_message.py "Continue playing"')
- //if (muPiBoxConfig.telegram.active && muPiBoxConfig.telegram.token.length > 1 && muPiBoxConfig.telegram.chatId.length > 1) cmdCall('/usr/bin/python3 /usr/local/bin/mupibox/telegram_Track_Spotify.py');
+ //if (hasConfiguredTelegram()) cmdCall('/usr/bin/python3 /usr/local/bin/mupibox/telegram_Track_Spotify.py');
} else if (currentMeta.currentPlayer === 'mplayer') {
if (!currentMeta.playing) {
player.playPause()
currentMeta.pause = false
//currentMeta.playing = true;
writeplayerstatePlay()
- if (
- muPiBoxConfig.telegram.active &&
- //network.onlinestate === 'online' &&
- muPiBoxConfig.telegram.token.length > 1 &&
- muPiBoxConfig.telegram.chatId.length > 1
- )
+ if (hasConfiguredTelegram())
cmdCall('/usr/bin/python3 /usr/local/bin/mupibox/telegram_send_message.py "Continue playing"')
// if (muPiBoxConfig.telegram.active && muPiBoxConfig.telegram.token.length > 1 && muPiBoxConfig.telegram.chatId.length > 1 && (currentMeta.currentType === 'rss' || currentMeta.currentType === 'radio')) cmdCall('/usr/bin/python3 /usr/local/bin/mupibox/telegram_Track_Local.py');
// if (muPiBoxConfig.telegram.active && muPiBoxConfig.telegram.token.length > 1 && muPiBoxConfig.telegram.chatId.length > 1 && currentMeta.currentType === 'local') cmdCall('/usr/bin/python3 /usr/local/bin/mupibox/telegram_Track_RSS_Radio.py');
@@ -1173,14 +1175,9 @@ function playMe() {
log.debug(`${nowDate.toLocaleString()}: [Spotify Control] Playback started`)
writeplayerstatePlay()
spotifyRunning = true
- if (
- muPiBoxConfig.telegram.active &&
- //network.onlinestate === 'online' &&
- muPiBoxConfig.telegram.token.length > 1 &&
- muPiBoxConfig.telegram.chatId.length > 1
- )
+ if (hasConfiguredTelegram())
cmdCall('/usr/bin/python3 /usr/local/bin/mupibox/telegram_send_message.py "Start playing spotify"')
- //if (muPiBoxConfig.telegram.active && muPiBoxConfig.telegram.token.length > 1 && muPiBoxConfig.telegram.chatId.length > 1) cmdCall('/usr/bin/python3 /usr/local/bin/mupibox/telegram_Track_Spotify.py');
+ //if (hasConfiguredTelegram()) cmdCall('/usr/bin/python3 /usr/local/bin/mupibox/telegram_Track_Spotify.py');
},
(err) => {
log.debug(`${nowDate.toLocaleString()}: [Spotify Control] Playback error${err}`)
@@ -1205,14 +1202,9 @@ function playMe() {
}
writeplayerstatePlay()
spotifyRunning = true
- if (
- muPiBoxConfig.telegram.active &&
- //network.onlinestate === 'online' &&
- muPiBoxConfig.telegram.token.length > 1 &&
- muPiBoxConfig.telegram.chatId.length > 1
- )
+ if (hasConfiguredTelegram())
cmdCall('/usr/bin/python3 /usr/local/bin/mupibox/telegram_send_message.py "Start playing spotify"')
- //if (muPiBoxConfig.telegram.active && muPiBoxConfig.telegram.token.length > 1 && muPiBoxConfig.telegram.chatId.length > 1) cmdCall('/usr/bin/python3 /usr/local/bin/mupibox/telegram_Track_Spotify.py');
+ //if (hasConfiguredTelegram()) cmdCall('/usr/bin/python3 /usr/local/bin/mupibox/telegram_Track_Spotify.py');
},
(err) => {
log.debug(`${nowDate.toLocaleString()}: [Spotify Control] Playback error${err}`)
@@ -1242,14 +1234,9 @@ function playList(playedList) {
currentMeta.currentTracknr = 0
currentMeta.path = playedTitelmod
- if (
- muPiBoxConfig.telegram.active &&
- //network.onlinestate === 'online' &&
- muPiBoxConfig.telegram.token.length > 1 &&
- muPiBoxConfig.telegram.chatId.length > 1
- )
+ if (hasConfiguredTelegram())
cmdCall('/usr/bin/python3 /usr/local/bin/mupibox/telegram_send_message.py "Start playing local"')
- //if (muPiBoxConfig.telegram.active && muPiBoxConfig.telegram.token.length > 1 && muPiBoxConfig.telegram.chatId.length > 1) cmdCall('/usr/bin/python3 /usr/local/bin/mupibox/telegram_Track_Local.py');
+ //if (hasConfiguredTelegram()) cmdCall('/usr/bin/python3 /usr/local/bin/mupibox/telegram_Track_Local.py');
setTimeout(() => {
const cmdtotalTracks = `find "/home/dietpi/MuPiBox/media/${decodeURIComponent(currentMeta.path)}" -type f -name "*.mp3" -or -name "*.flac" -or -name "*.m4a" -or -name "*.wma" -or -name "*.wav"| wc -l`
@@ -1284,14 +1271,9 @@ function playURL(playedURL) {
player.play(playedURL)
player.setVolume(volumeStart)
log.debug(`${nowDate.toLocaleString()}: ${playedURL}`)
- if (
- muPiBoxConfig.telegram.active &&
- //network.onlinestate === 'online' &&
- muPiBoxConfig.telegram.token.length > 1 &&
- muPiBoxConfig.telegram.chatId.length > 1
- )
+ if (hasConfiguredTelegram())
cmdCall('/usr/bin/python3 /usr/local/bin/mupibox/telegram_send_message.py "Start playing stream"')
- //if (muPiBoxConfig.telegram.active && muPiBoxConfig.telegram.token.length > 1 && muPiBoxConfig.telegram.chatId.length > 1) cmdCall('/usr/bin/python3 /usr/local/bin/mupibox/telegram_Track_RSS_Radio.py');
+ //if (hasConfiguredTelegram()) cmdCall('/usr/bin/python3 /usr/local/bin/mupibox/telegram_Track_RSS_Radio.py');
}
/*seek 30 secends back or forward*/
From 5424590287ca0a7b498491d1b4415e3600601ee3 Mon Sep 17 00:00:00 2001
From: Waldemar Berg
Date: Fri, 8 May 2026 14:24:17 +0200
Subject: [PATCH 20/20] AR5-2 + AR5-7: validate Telegram /vol and /sleep
arguments
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two combined bugs in telegram_receiver.py:
AR5-2 (Schutzfunktions-Bypass): /vol spliced the raw POST argument
into "%" and called amixer directly. The maxVolume cap from MED-1
(Hörschutz) only fires through the backend-player setVolume() flow —
the Telegram path was an unauthenticated bypass for any whitelisted
chat. Now: parse → clamp_volume() → max(0, min(v, mupibox.maxVolume)).
AR5-7 (Receiver-Thread crash): /vol or /sleep without an argument
hit `int(split_cmd[1])` and raised IndexError. telepot's MessageLoop
doesn't recover from that — the bot stayed deaf until pm2/systemd
restart. mupi_telegram.service has no Restart= directive (AR5-8),
so a malformed message could brick the bot until reboot. Now:
parse_int_arg with default=None, explicit usage-message on bad input.
Same clamp applied to vol_*/sleep_* inline-keyboard callbacks for
defense-in-depth — the current button values are hardcoded so this
is preventive, not actively exploitable.
---
scripts/telegram/telegram_receiver.py | 76 +++++++++++++++++++++------
1 file changed, 60 insertions(+), 16 deletions(-)
diff --git a/scripts/telegram/telegram_receiver.py b/scripts/telegram/telegram_receiver.py
index b404a45c..1005e0ef 100644
--- a/scripts/telegram/telegram_receiver.py
+++ b/scripts/telegram/telegram_receiver.py
@@ -73,6 +73,33 @@ def parse_int_arg(command, default=None):
return default
return default
+# AR5-2: /vol used to splice the raw post-arg into "%" and pass it
+# straight to amixer — no range check, no maxVolume cap. The Hörschutz
+# clamp from MED-1 lives only in the backend-player setVolume() flow,
+# so the Telegram /vol path was an unauthenticated bypass for any
+# whitelisted chat. Plus IndexError if /vol is sent without an arg
+# crashed the receiver thread (AR5-7). This helper handles both.
+def clamp_volume(raw):
+ try:
+ v = int(raw)
+ except (ValueError, TypeError):
+ return None
+ max_vol = 100
+ try:
+ max_vol = int(config['mupibox'].get('maxVolume', 100))
+ except (ValueError, TypeError, KeyError):
+ pass
+ return max(0, min(v, max_vol))
+
+# AR5-7: /sleep previously called int(split_cmd[1]) without try/except;
+# /sleep without an arg was an IndexError that killed the receiver thread.
+def clamp_sleep_minutes(raw):
+ try:
+ v = int(raw)
+ except (ValueError, TypeError):
+ return None
+ return max(1, min(v, 1440)) # 1 min … 24 h
+
def call_api_post(path, body=None):
try:
r = requests.post(f'{API_BASE}{path}', json=(body or {}), timeout=5)
@@ -144,15 +171,22 @@ def on_chat_message(msg):
elif command == '/reboot':
subprocess.run(["sudo", "reboot"])
elif command[:4] == '/vol':
- split_cmd = command.split(" ")
- volume = split_cmd[1]+"%"
- subprocess.run(["/usr/bin/amixer", "sset", "Master", volume])
- bot.sendMessage(chat_id, "Volume set to "+volume)
+ # AR5-2 / AR5-7: validate, clamp to [0, maxVolume]; reject missing/non-int args.
+ v = clamp_volume(parse_int_arg(command, default=None))
+ if v is None:
+ bot.sendMessage(chat_id, "Usage: /vol <0-maxVolume>")
+ else:
+ volume = f"{v}%"
+ subprocess.run(["/usr/bin/amixer", "sset", "Master", volume])
+ bot.sendMessage(chat_id, "Volume set to " + volume)
elif command[:6] == '/sleep':
- split_cmd = command.split(" ")
- sleep = int(split_cmd[1]) * 60
- subprocess.Popen(["sudo", "nohup", "/usr/local/bin/mupibox/./sleep_timer.sh", str(sleep)])
- bot.sendMessage(chat_id, "Sleep timer set to "+split_cmd[1]+" minutes")
+ # AR5-7: validate, reject missing/non-int args.
+ mins = clamp_sleep_minutes(parse_int_arg(command, default=None))
+ if mins is None:
+ bot.sendMessage(chat_id, "Usage: /sleep <1-1440>")
+ else:
+ subprocess.Popen(["sudo", "nohup", "/usr/local/bin/mupibox/./sleep_timer.sh", str(mins * 60)])
+ bot.sendMessage(chat_id, f"Sleep timer set to {mins} minutes")
elif command == '/status':
status_code, body = call_api_get('/playtime')
if status_code == 200:
@@ -302,15 +336,25 @@ def on_callback_query(msg):
msg_idf = telepot.message_identifier(message_with_inline_keyboard)
bot.editMessageText(msg_idf, 'In how many minutes should the MuPiBox go to sleep?', reply_markup = markup )
elif query_data[:4] == 'vol_':
- split_cmd = query_data.split("_")
- volume = split_cmd[1]+"%"
- subprocess.run(["/usr/bin/amixer", "sset", "Master", volume])
- bot.answerCallbackQuery(query_id, text='Volume set to ' + volume, show_alert=True)
+ # Inline-keyboard values are hardcoded (10/30/50/70/100) but defense-
+ # in-depth: clamp anyway so a future button change can't bypass
+ # maxVolume. Same for /sleep_ below.
+ parts = query_data.split("_", 1)
+ v = clamp_volume(parts[1] if len(parts) > 1 else None)
+ if v is None:
+ bot.answerCallbackQuery(query_id, text='Invalid volume', show_alert=True)
+ else:
+ volume = f"{v}%"
+ subprocess.run(["/usr/bin/amixer", "sset", "Master", volume])
+ bot.answerCallbackQuery(query_id, text='Volume set to ' + volume, show_alert=True)
elif query_data[:6] == 'sleep_':
- split_cmd = query_data.split("_")
- sleep = int(split_cmd[1]) * 60
- subprocess.Popen(["sudo", "nohup", "/usr/local/bin/mupibox/./sleep_timer.sh", str(sleep)])
- bot.sendMessage(from_id, "Sleep timer set to " + split_cmd[1] + " minutes")
+ parts = query_data.split("_", 1)
+ mins = clamp_sleep_minutes(parts[1] if len(parts) > 1 else None)
+ if mins is None:
+ bot.answerCallbackQuery(query_id, text='Invalid sleep value', show_alert=True)
+ else:
+ subprocess.Popen(["sudo", "nohup", "/usr/local/bin/mupibox/./sleep_timer.sh", str(mins * 60)])
+ bot.sendMessage(from_id, f"Sleep timer set to {mins} minutes")
elif query_data == 'play':
url = 'http://' + config['mupibox']['host'] + ':5005//play'
bot.answerCallbackQuery(query_id, text='Play', show_alert=True)