From d4ddcdbe7b9e64a5264fbc3ee449aaf19acefef3 Mon Sep 17 00:00:00 2001 From: Waldemar Berg Date: Wed, 6 May 2026 11:47:06 +0200 Subject: [PATCH 01/20] backend-player: add daily playtime limit with per-weekday config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a per-weekday daily listening cap to the player. When the limit is reached, playback is stopped and new play/resume/skip commands are rejected with HTTP 423. Pause/stop/volume/system commands stay available so the box remains usable. Why: enables parents to cap daily listening time (and set 0 minutes for specific days, e.g. no usage on Sunday) — a feature many parent boxes need but the upstream UI doesn't expose yet. Implementation: - Counter increments once per second when isActuallyPlaying() is true (covers both Spotify and mplayer playback paths). - Working state in /tmp/playtime.json (tmpfs — no SD wear). - Persistent checkpoint in configBasePath, written at most every 60s on change, plus on day rollover and just-blocked transition. - Logical day shifted by configurable resetHour so e.g. listening past midnight still counts against the previous day. - Config under "playtimeLimit" in mupiboxconfig.json; missing block defaults to enabled=false so existing installs are unaffected. Config template gets the new block with safe defaults (60 min/day, disabled by default). API endpoint and frontend integration follow in subsequent commits. --- config/templates/mupiboxconfig.json | 13 ++ src/backend-player/src/spotify-control.js | 182 ++++++++++++++++++++++ 2 files changed, 195 insertions(+) diff --git a/config/templates/mupiboxconfig.json b/config/templates/mupiboxconfig.json index 12d6a89a..6b67b6e8 100644 --- a/config/templates/mupiboxconfig.json +++ b/config/templates/mupiboxconfig.json @@ -265,6 +265,19 @@ "idleDisplayOff": "10", "pressDelay": "2" }, + "playtimeLimit": { + "enabled": false, + "resetHour": 0, + "limitsMinutes": { + "mon": 60, + "tue": 60, + "wed": 60, + "thu": 60, + "fri": 60, + "sat": 60, + "sun": 60 + } + }, "shim": { "poweroffPin": "4", "triggerPin": "17", diff --git a/src/backend-player/src/spotify-control.js b/src/backend-player/src/spotify-control.js index 732da894..6c6b0859 100644 --- a/src/backend-player/src/spotify-control.js +++ b/src/backend-player/src/spotify-control.js @@ -180,6 +180,177 @@ const currentMeta = { volume: 0, } +// === Playtime Limit (daily listening cap) === +// Per-weekday limit on active playback time. Configured in mupiboxconfig.json +// under "playtimeLimit". Working state lives in /tmp (tmpfs, no SD wear); +// a checkpoint on the SD card persists across reboots, written at most every 60s. +// Config changes require a player restart (consistent with other config in this file). +const PLAYTIME_DAY_KEYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'] +const PLAYTIME_DEFAULT_LIMITS = { mon: 60, tue: 60, wed: 60, thu: 60, fri: 60, sat: 60, sun: 60 } +const PLAYTIME_WORKING_PATH = '/tmp/playtime.json' +const PLAYTIME_CHECKPOINT_PATH = path.join(configBasePath, 'playtime-checkpoint.json') +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 + return { + enabled: raw.enabled === true, + resetHour, + limitsMinutes: { ...PLAYTIME_DEFAULT_LIMITS, ...(raw.limitsMinutes || {}) }, + } +} + +// 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) + const y = shifted.getFullYear() + const m = String(shifted.getMonth() + 1).padStart(2, '0') + const d = String(shifted.getDate()).padStart(2, '0') + return { dateStr: `${y}-${m}-${d}`, dayKey: PLAYTIME_DAY_KEYS[shifted.getDay()] } +} + +function isActuallyPlaying() { + if (!currentMeta.currentPlayer) return false + if (currentMeta.currentPlayer === 'spotify') return currentMeta.pause === false + if (currentMeta.currentPlayer === 'mplayer') return currentMeta.playing === true + return false +} + +const playtimeState = { + date: '', + dayKey: 'mon', + usedSeconds: 0, + blocked: false, +} +let playtimeLastCheckpointAt = 0 +let playtimeLastCheckpointSeconds = -1 + +function loadPlaytimeCheckpoint() { + try { + if (!fs.existsSync(PLAYTIME_CHECKPOINT_PATH)) return + const data = JSON.parse(fs.readFileSync(PLAYTIME_CHECKPOINT_PATH, 'utf8')) + const today = getLogicalDay(new Date(), readPlaytimeConfig().resetHour) + if (data && data.date === today.dateStr) { + playtimeState.date = data.date + playtimeState.dayKey = data.dayKey || today.dayKey + playtimeState.usedSeconds = Number(data.usedSeconds) || 0 + log.info( + `${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) + } +} + +function writePlaytimeWorking() { + const cfg = readPlaytimeConfig() + const limit = cfg.limitsMinutes[playtimeState.dayKey] ?? 60 + const payload = { + enabled: cfg.enabled, + date: playtimeState.date, + dayKey: playtimeState.dayKey, + limitMinutes: limit, + usedSeconds: playtimeState.usedSeconds, + remainingSeconds: Math.max(0, limit * 60 - playtimeState.usedSeconds), + blocked: playtimeState.blocked, + resetHour: cfg.resetHour, + } + fs.writeFile(PLAYTIME_WORKING_PATH, JSON.stringify(payload), () => {}) +} + +function writePlaytimeCheckpoint() { + const payload = { + date: playtimeState.date, + dayKey: playtimeState.dayKey, + usedSeconds: playtimeState.usedSeconds, + } + fs.writeFile(PLAYTIME_CHECKPOINT_PATH, JSON.stringify(payload), (err) => { + if (err) log.error(`${new Date().toLocaleString()}: [Playtime] Failed to write checkpoint:`, err) + }) + playtimeLastCheckpointAt = Date.now() + playtimeLastCheckpointSeconds = playtimeState.usedSeconds +} + +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 +} + +// 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) { + if (command.name?.includes('spotify:')) return true + if ( + command.dir && + (command.dir.includes('library') || + command.dir.includes('radio') || + command.dir.includes('rss') || + command.dir.includes('say/')) + ) { + return true + } + if (['play', 'next', 'previous', 'seek+30', 'seek-30'].includes(command.name)) return true + if (command.name?.startsWith('seekpos:')) return true + return false +} + +function playtimeTick() { + const cfg = readPlaytimeConfig() + if (!cfg.enabled) { + if (playtimeState.blocked) playtimeState.blocked = false + return + } + const now = new Date() + const today = getLogicalDay(now, cfg.resetHour) + // Day rollover: reset counter + if (today.dateStr !== playtimeState.date) { + playtimeState.date = today.dateStr + playtimeState.dayKey = today.dayKey + playtimeState.usedSeconds = 0 + playtimeState.blocked = false + writePlaytimeCheckpoint() + log.info(`${now.toLocaleString()}: [Playtime] New day: ${today.dateStr} (${today.dayKey})`) + } + // Increment counter only when actually playing + if (isActuallyPlaying()) { + playtimeState.usedSeconds++ + } + // Check the limit + 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) + } + writePlaytimeCheckpoint() + } + // 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 + ) { + writePlaytimeCheckpoint() + } +} + +loadPlaytimeCheckpoint() +setInterval(playtimeTick, 1000) + function writeplayerstatePlay() { playerstate = 'play' fs.writeFile('/tmp/playerstate', playerstate, (err) => { @@ -1029,6 +1200,17 @@ app.use((req, res) => { const command = path.parse(req.url) 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. + // Pause/stop/volume/system commands fall through normally. + if (isPlaytimeBlocked() && isPlayInitiatingCommand(command)) { + log.info( + `${new Date().toLocaleString()}: [Playtime] Rejected command (limit reached): name=${command.name} dir=${command.dir}`, + ) + res.status(423).send({ status: 'blocked', error: 'playtime_limit_reached' }) + return + } + /*this is the first command to be received. It always includes the device id encoded in between two /*/ /*check this if we need to transfer the playback to a new device*/ if (command.name.includes('spotify:')) { From fb0ee9f7a4bd1dd77f678412bea0e342524839fe Mon Sep 17 00:00:00 2001 From: Waldemar Berg Date: Wed, 6 May 2026 12:04:11 +0200 Subject: [PATCH 02/20] backend-api: expose /api/playtime endpoint reading player tmpfs state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a read-only GET /api/playtime endpoint that surfaces the playtime counter (date, used/remaining seconds, blocked flag, limit for current weekday, etc.) so the frontend can show a remaining-time indicator and a blocked-overlay. The endpoint reads /tmp/playtime.json which the player rewrites once per second on tmpfs. Missing/unreadable file is treated as { enabled: false } — the frontend then simply hides the UI. No direct coupling to the player process: the file acts as a one-way status feed. Also tweaks the player so it always writes the working file (even when disabled), so a config change from enabled to disabled is reflected immediately on the next tick rather than leaving a stale file around. Adds a typed PlaytimeStatus model (discriminated by `enabled`) and extends MupiboxConfig with a typed playtimeLimit field for use by later admin/frontend work. --- .../src/models/mupibox-config.model.ts | 3 +++ src/backend-api/src/models/playtime.model.ts | 26 +++++++++++++++++++ src/backend-api/src/server.ts | 22 ++++++++++++++++ src/backend-player/src/spotify-control.js | 7 ++++- 4 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 src/backend-api/src/models/playtime.model.ts diff --git a/src/backend-api/src/models/mupibox-config.model.ts b/src/backend-api/src/models/mupibox-config.model.ts index 4d3176ef..1abfe877 100644 --- a/src/backend-api/src/models/mupibox-config.model.ts +++ b/src/backend-api/src/models/mupibox-config.model.ts @@ -1,7 +1,10 @@ +import type { PlaytimeLimitConfig } from './playtime.model' + export interface MupiboxConfig { spotify?: { disableScraperForPlaylists?: boolean [key: string]: unknown } + playtimeLimit?: PlaytimeLimitConfig [key: string]: unknown } diff --git a/src/backend-api/src/models/playtime.model.ts b/src/backend-api/src/models/playtime.model.ts new file mode 100644 index 00000000..40948050 --- /dev/null +++ b/src/backend-api/src/models/playtime.model.ts @@ -0,0 +1,26 @@ +export type PlaytimeDayKey = 'mon' | 'tue' | 'wed' | 'thu' | 'fri' | 'sat' | 'sun' + +export type PlaytimeLimitsMinutes = Partial> + +export interface PlaytimeLimitConfig { + enabled: boolean + resetHour?: number + limitsMinutes?: PlaytimeLimitsMinutes +} + +export type PlaytimeStatus = PlaytimeStatusEnabled | PlaytimeStatusDisabled + +export interface PlaytimeStatusDisabled { + enabled: false +} + +export interface PlaytimeStatusEnabled { + enabled: true + date: string + dayKey: PlaytimeDayKey + limitMinutes: number + usedSeconds: number + remainingSeconds: number + blocked: boolean + resetHour: number +} diff --git a/src/backend-api/src/server.ts b/src/backend-api/src/server.ts index 51399c7d..de246320 100644 --- a/src/backend-api/src/server.ts +++ b/src/backend-api/src/server.ts @@ -10,6 +10,7 @@ import ky from 'ky' import xmlparser from 'xml-js' import { LogRequest, LogResponse } from './models/log.model' import type { MupiboxConfig } from './models/mupibox-config.model' +import type { PlaytimeStatus } from './models/playtime.model' import { ServerConfig } from './models/server.model' import type { SpotifyValidationRequest, SpotifyValidationResponse } from './models/spotify-api.model' import { SpotifyApiService } from './services/spotify-api.service' @@ -62,6 +63,7 @@ const wlanFile = `${configBasePath}/wlan.json` const monitorFile = `${configBasePath}/monitor.json` const albumstopFile = `${configBasePath}/albumstop.json` const mupihat = '/tmp/mupihat.json' +const playtimeFile = '/tmp/playtime.json' const dataLock = '/tmp/.data.lock' const resumeLock = '/tmp/.resume.lock' @@ -163,6 +165,26 @@ app.get('/api/mupihat', (_req, res) => { } }) +// Playback time tracking written by backend-player to /tmp/playtime.json (tmpfs). +// Missing/unreadable file means the player hasn't ticked yet or the feature is off — +// either way, surfaces as "disabled" so the frontend can hide the UI safely. +app.get('/api/playtime', (_req, res) => { + const disabled: PlaytimeStatus = { enabled: false } + if (!fs.existsSync(playtimeFile)) { + res.json(disabled) + return + } + jsonfile.readFile(playtimeFile, (error, data) => { + if (error) { + console.log(`${new Date().toLocaleString()}: [MuPiBox-Server] Error /api/playtime read playtime.json`) + console.log(`${new Date().toLocaleString()}: [MuPiBox-Server] ${error}`) + res.json(disabled) + } else { + res.json(data) + } + }) +}) + 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 6c6b0859..f89083c2 100644 --- a/src/backend-player/src/spotify-control.js +++ b/src/backend-player/src/spotify-control.js @@ -246,9 +246,13 @@ function loadPlaytimeCheckpoint() { function writePlaytimeWorking() { const cfg = readPlaytimeConfig() + if (!cfg.enabled) { + fs.writeFile(PLAYTIME_WORKING_PATH, JSON.stringify({ enabled: false }), () => {}) + return + } const limit = cfg.limitsMinutes[playtimeState.dayKey] ?? 60 const payload = { - enabled: cfg.enabled, + enabled: true, date: playtimeState.date, dayKey: playtimeState.dayKey, limitMinutes: limit, @@ -303,6 +307,7 @@ function playtimeTick() { const cfg = readPlaytimeConfig() if (!cfg.enabled) { if (playtimeState.blocked) playtimeState.blocked = false + writePlaytimeWorking() return } const now = new Date() From 2f07a2adbf4ebe41a2a24b9c069a5bd1a2b6b4cf Mon Sep 17 00:00:00 2001 From: Waldemar Berg Date: Wed, 6 May 2026 12:23:15 +0200 Subject: [PATCH 03/20] frontend-box: add playtime remaining chip and blocked overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two UI elements driven by GET /api/playtime polling (every 30s): - Remaining-time chip in the home page header (next to status icons). Self-hides when the feature is disabled or the limit is hit. Color shifts at 30 min (yellow) and 10 min (red) so the kid notices time running out before the overlay kicks in. - Full-screen blocked overlay rendered at app level when blocked is true. Uses the same overlay pattern as the existing monitor blocker. Friendly German message ("Heute war genug Musik / Morgen geht's weiter") so it reads as a gentle stop, not a punishment. Settings UI stays out of the touch frontend on purpose — playtime limits will be edited only via the AdminInterface so the kid can't change them. PlaytimeService is the single source of truth: HTTP-polls /api/playtime into a Signal that both components read. catchError keeps the UI working when the API momentarily isn't reachable (treat as disabled). --- src/frontend-box/src/app/app.component.html | 3 ++ src/frontend-box/src/app/app.component.ts | 12 +++++- src/frontend-box/src/app/home/home.page.html | 1 + src/frontend-box/src/app/home/home.page.ts | 2 + .../playtime-blocked-overlay.component.html | 7 ++++ .../playtime-blocked-overlay.component.scss | 41 +++++++++++++++++++ .../playtime-blocked-overlay.component.ts | 17 ++++++++ .../playtime-chip.component.html | 6 +++ .../playtime-chip.component.scss | 29 +++++++++++++ .../playtime-chip/playtime-chip.component.ts | 40 ++++++++++++++++++ src/frontend-box/src/app/playtime.model.ts | 18 ++++++++ src/frontend-box/src/app/playtime.service.ts | 24 +++++++++++ 12 files changed, 198 insertions(+), 2 deletions(-) create mode 100644 src/frontend-box/src/app/playtime-blocked-overlay/playtime-blocked-overlay.component.html create mode 100644 src/frontend-box/src/app/playtime-blocked-overlay/playtime-blocked-overlay.component.scss create mode 100644 src/frontend-box/src/app/playtime-blocked-overlay/playtime-blocked-overlay.component.ts create mode 100644 src/frontend-box/src/app/playtime-chip/playtime-chip.component.html create mode 100644 src/frontend-box/src/app/playtime-chip/playtime-chip.component.scss create mode 100644 src/frontend-box/src/app/playtime-chip/playtime-chip.component.ts create mode 100644 src/frontend-box/src/app/playtime.model.ts create mode 100644 src/frontend-box/src/app/playtime.service.ts diff --git a/src/frontend-box/src/app/app.component.html b/src/frontend-box/src/app/app.component.html index 212bf92f..a18b4230 100644 --- a/src/frontend-box/src/app/app.component.html +++ b/src/frontend-box/src/app/app.component.html @@ -2,5 +2,8 @@ @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 794b5148..84ed4cae 100644 --- a/src/frontend-box/src/app/app.component.ts +++ b/src/frontend-box/src/app/app.component.ts @@ -1,5 +1,5 @@ import { HttpClient } from '@angular/common/http' -import { ChangeDetectionStrategy, Component, Signal } from '@angular/core' +import { ChangeDetectionStrategy, Component, computed, 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' @@ -7,21 +7,25 @@ import { environment } from 'src/environments/environment' import { DisplayManagerService } from './display-manager.service' import { ExternalPlaybackNavigatorService } from './external-playback-navigator.service' import { Monitor } from './monitor' +import { PlaytimeService } from './playtime.service' +import { PlaytimeBlockedOverlayComponent } from './playtime-blocked-overlay/playtime-blocked-overlay.component' @Component({ selector: 'app-root', templateUrl: 'app.component.html', styleUrls: ['app.component.scss'], - imports: [IonApp, IonRouterOutlet], + imports: [IonApp, IonRouterOutlet, PlaytimeBlockedOverlayComponent], changeDetection: ChangeDetectionStrategy.OnPush, }) export class AppComponent { protected monitorOff: Signal + protected playtimeBlocked: Signal public constructor( private http: HttpClient, _externalPlaybackNavigator: ExternalPlaybackNavigatorService, _displayManager: DisplayManagerService, + playtimeService: PlaytimeService, ) { this.monitorOff = toSignal( // 1.5s should be enough to be somewhat "recent". @@ -32,5 +36,9 @@ export class AppComponent { ), { initialValue: false }, ) + this.playtimeBlocked = computed(() => { + const s = playtimeService.status() + return s.enabled === true && s.blocked + }) } } diff --git a/src/frontend-box/src/app/home/home.page.html b/src/frontend-box/src/app/home/home.page.html index 5d19fd43..a7667024 100644 --- a/src/frontend-box/src/app/home/home.page.html +++ b/src/frontend-box/src/app/home/home.page.html @@ -18,6 +18,7 @@ + @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 b0f702c9..565fdacf 100644 --- a/src/frontend-box/src/app/home/home.page.ts +++ b/src/frontend-box/src/app/home/home.page.ts @@ -28,6 +28,7 @@ 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' @@ -37,6 +38,7 @@ 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-blocked-overlay/playtime-blocked-overlay.component.html b/src/frontend-box/src/app/playtime-blocked-overlay/playtime-blocked-overlay.component.html new file mode 100644 index 00000000..23a5ad85 --- /dev/null +++ b/src/frontend-box/src/app/playtime-blocked-overlay/playtime-blocked-overlay.component.html @@ -0,0 +1,7 @@ +
+
+ +

Heute war genug Musik

+

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 new file mode 100644 index 00000000..184ee490 --- /dev/null +++ b/src/frontend-box/src/app/playtime-blocked-overlay/playtime-blocked-overlay.component.scss @@ -0,0 +1,41 @@ +.playtime-blocked-overlay { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + z-index: 9999; + background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%); + display: flex; + align-items: center; + justify-content: center; + text-align: center; + user-select: none; + cursor: default; + + .content { + color: #ffffff; + padding: 2rem; + max-width: 90%; + } + + .icon { + font-size: 96px; + margin-bottom: 1.5rem; + opacity: 0.85; + } + + h1 { + font-size: 2rem; + font-weight: 600; + margin: 0 0 0.5rem 0; + line-height: 1.2; + } + + p { + font-size: 1.25rem; + opacity: 0.8; + margin: 0; + line-height: 1.3; + } +} 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 new file mode 100644 index 00000000..cae46a16 --- /dev/null +++ b/src/frontend-box/src/app/playtime-blocked-overlay/playtime-blocked-overlay.component.ts @@ -0,0 +1,17 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core' +import { IonIcon } from '@ionic/angular/standalone' +import { addIcons } from 'ionicons' +import { moonOutline } from 'ionicons/icons' + +@Component({ + selector: 'mupi-playtime-blocked', + templateUrl: './playtime-blocked-overlay.component.html', + styleUrls: ['./playtime-blocked-overlay.component.scss'], + imports: [IonIcon], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class PlaytimeBlockedOverlayComponent { + constructor() { + addIcons({ moonOutline }) + } +} diff --git a/src/frontend-box/src/app/playtime-chip/playtime-chip.component.html b/src/frontend-box/src/app/playtime-chip/playtime-chip.component.html new file mode 100644 index 00000000..99c60b34 --- /dev/null +++ b/src/frontend-box/src/app/playtime-chip/playtime-chip.component.html @@ -0,0 +1,6 @@ +@if (visible()) { +
+ + {{ remainingMinutes() }} min +
+} 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 new file mode 100644 index 00000000..3713d788 --- /dev/null +++ b/src/frontend-box/src/app/playtime-chip/playtime-chip.component.scss @@ -0,0 +1,29 @@ +.playtime-chip { + 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); + user-select: none; + pointer-events: none; + white-space: nowrap; + + ion-icon { + font-size: 16px; + } + + &[data-level='warning'] { + background: rgba(255, 193, 7, 0.85); + color: #1a1a1a; + } + + &[data-level='critical'] { + background: rgba(244, 67, 54, 0.9); + color: #ffffff; + } +} 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 new file mode 100644 index 00000000..21befc08 --- /dev/null +++ b/src/frontend-box/src/app/playtime-chip/playtime-chip.component.ts @@ -0,0 +1,40 @@ +import { ChangeDetectionStrategy, Component, computed, inject, Signal } from '@angular/core' +import { IonIcon } from '@ionic/angular/standalone' +import { addIcons } from 'ionicons' +import { timeOutline } from 'ionicons/icons' +import { PlaytimeService } from '../playtime.service' + +type ChipLevel = 'normal' | 'warning' | 'critical' + +@Component({ + selector: 'mupi-playtime-chip', + templateUrl: './playtime-chip.component.html', + styleUrls: ['./playtime-chip.component.scss'], + imports: [IonIcon], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class PlaytimeChipComponent { + private playtimeService = inject(PlaytimeService) + + protected readonly visible: Signal = computed(() => { + const s = this.playtimeService.status() + return s.enabled === true && !s.blocked + }) + + protected readonly remainingMinutes: Signal = computed(() => { + const s = this.playtimeService.status() + if (s.enabled !== true) return 0 + return Math.ceil(s.remainingSeconds / 60) + }) + + protected readonly level: Signal = computed(() => { + const m = this.remainingMinutes() + if (m < 10) return 'critical' + if (m < 30) return 'warning' + return 'normal' + }) + + constructor() { + addIcons({ timeOutline }) + } +} diff --git a/src/frontend-box/src/app/playtime.model.ts b/src/frontend-box/src/app/playtime.model.ts new file mode 100644 index 00000000..53d76c83 --- /dev/null +++ b/src/frontend-box/src/app/playtime.model.ts @@ -0,0 +1,18 @@ +export type PlaytimeDayKey = 'mon' | 'tue' | 'wed' | 'thu' | 'fri' | 'sat' | 'sun' + +export type PlaytimeStatus = PlaytimeStatusEnabled | PlaytimeStatusDisabled + +export interface PlaytimeStatusDisabled { + enabled: false +} + +export interface PlaytimeStatusEnabled { + enabled: true + date: string + dayKey: PlaytimeDayKey + limitMinutes: number + usedSeconds: number + remainingSeconds: number + blocked: boolean + resetHour: number +} diff --git a/src/frontend-box/src/app/playtime.service.ts b/src/frontend-box/src/app/playtime.service.ts new file mode 100644 index 00000000..a8a92a0e --- /dev/null +++ b/src/frontend-box/src/app/playtime.service.ts @@ -0,0 +1,24 @@ +import { HttpClient } from '@angular/common/http' +import { Injectable, inject, Signal } from '@angular/core' +import { toSignal } from '@angular/core/rxjs-interop' +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 + +@Injectable({ providedIn: 'root' }) +export class PlaytimeService { + private http = inject(HttpClient) + + readonly status: Signal = toSignal( + timer(0, POLL_INTERVAL_MS).pipe( + switchMap(() => + this.http + .get(`${environment.backend.apiUrl}/playtime`) + .pipe(catchError(() => of({ enabled: false }))), + ), + ), + { initialValue: { enabled: false } as PlaytimeStatus }, + ) +} From 4ce1e6af350fa1de244b6aa6fa090aa1dee7e08b Mon Sep 17 00:00:00 2001 From: Waldemar Berg Date: Wed, 6 May 2026 12:28:06 +0200 Subject: [PATCH 04/20] admin: add Daily Playtime Limit section to MuPi-Conf page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new "Daily playtime limit" details section to mupi.php between the existing Timer and System settings. Lets the parent configure: - enabled / disabled - reset hour (0-23, controls when "today" resets — 4 helps with late-evening listening on weekends) - minutes per weekday (Mon-Sun, 0-1440 each, where 0 fully blocks that day) Save uses the existing $change=2 flow (write mupiboxconfig.json + setting_update.sh) and additionally restarts the player via `pm2 restart spotify-control`, since spotify-control.js loads mupiboxconfig.json once via require() at startup. Without the restart the player would keep using the previous limits. Falls back to safe defaults (60 min/day, disabled) when the playtimeLimit block is missing from an existing installation, so boxes upgraded in place don't need a manual config edit before opening the page. --- AdminInterface/www/mupi.php | 91 ++++++++++++++++++++++++++++++++++++- 1 file changed, 90 insertions(+), 1 deletion(-) diff --git a/AdminInterface/www/mupi.php b/AdminInterface/www/mupi.php index 7b1441fb..e353090c 100644 --- a/AdminInterface/www/mupi.php +++ b/AdminInterface/www/mupi.php @@ -441,6 +441,34 @@ $CHANGE_TXT=$CHANGE_TXT."
  • Press Button delay set to ".$_POST['pressDelay']. " seconds
  • "; $change=2; } + $playtime_changed = false; + if( $_POST['playtime_save'] ) + { + if( !isset($data["playtimeLimit"]) || !is_array($data["playtimeLimit"]) ) + { + $data["playtimeLimit"] = array( + "enabled" => false, + "resetHour" => 0, + "limitsMinutes" => array("mon"=>60,"tue"=>60,"wed"=>60,"thu"=>60,"fri"=>60,"sat"=>60,"sun"=>60), + ); + } + if( !isset($data["playtimeLimit"]["limitsMinutes"]) || !is_array($data["playtimeLimit"]["limitsMinutes"]) ) + { + $data["playtimeLimit"]["limitsMinutes"] = array("mon"=>60,"tue"=>60,"wed"=>60,"thu"=>60,"fri"=>60,"sat"=>60,"sun"=>60); + } + $data["playtimeLimit"]["enabled"] = (isset($_POST['playtime_enabled']) && $_POST['playtime_enabled'] === '1'); + $data["playtimeLimit"]["resetHour"] = max(0, min(23, intval($_POST['playtime_resetHour']))); + $playtime_days = array('mon','tue','wed','thu','fri','sat','sun'); + foreach( $playtime_days as $d ) + { + $field = 'playtime_limit_' . $d; + $val = isset($_POST[$field]) ? intval($_POST[$field]) : 60; + $data["playtimeLimit"]["limitsMinutes"][$d] = max(0, min(1440, $val)); + } + $playtime_changed = true; + $CHANGE_TXT = $CHANGE_TXT."
  • Playtime limit settings saved (player restarting...)
  • "; + $change = 2; + } if( $data["shim"]["ledPin"]!=$_POST['ledPin'] && $_POST['ledPin']) { $data["shim"]["ledPin"]=$_POST['ledPin']; @@ -569,7 +597,12 @@ 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"); + } + $CHANGE_TXT=$CHANGE_TXT.""; ?> @@ -695,6 +728,62 @@ +
    + Daily playtime limit +
      +
    • +

      About

      +

      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).

      +
    • +
    • +

      Status

      + Currently: '.($playtime_enabled_state ? 'ENABLED' : 'DISABLED').'

      '; + ?> +

      Enable / disable the daily limit:

      + +
    • +
    • +

      Reset hour (0 - 23)

      +

      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.

      + +
    • +
    • +

      Daily limit per weekday (minutes)

      +

      Set 0 to block playback entirely on that day. Maximum 1440 (= 24 h).

      + + + 'Monday', + 'tue' => 'Tuesday', + 'wed' => 'Wednesday', + 'thu' => 'Thursday', + 'fri' => 'Friday', + 'sat' => 'Saturday', + 'sun' => 'Sunday', + ); + foreach( $playtime_day_labels as $key => $label ) + { + $val = isset($playtime_limits[$key]) ? intval($playtime_limits[$key]) : 60; + echo ''; + } + ?> +
      DayMinutes per day
      '.$label.' min
      +
    • +
    • + + +
    • +
    +
    +
    System settings
      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."
    • Playtime limit settings saved (player restarting...)
    • "; $change = 2; } + if( $_POST['quiethours_save'] ) + { + if( !isset($data["quietHours"]) || !is_array($data["quietHours"]) ) + { + $data["quietHours"] = array( + "enabled" => false, + "maxOverrunMinutes" => 10, + "schedule" => array("mon"=>array(),"tue"=>array(),"wed"=>array(),"thu"=>array(),"fri"=>array(),"sat"=>array(),"sun"=>array()), + ); + } + $data["quietHours"]["enabled"] = (isset($_POST['quiethours_enabled']) && $_POST['quiethours_enabled'] === '1'); + $data["quietHours"]["maxOverrunMinutes"] = max(0, min(60, intval($_POST['quiethours_maxOverrunMinutes']))); + $quiethours_days = array('mon','tue','wed','thu','fri','sat','sun'); + $quiethours_window_count = 0; + foreach( $quiethours_days as $d ) + { + $rawWindows = isset($_POST['quiet_windows'][$d]) && is_array($_POST['quiet_windows'][$d]) ? $_POST['quiet_windows'][$d] : array(); + $cleaned = array(); + foreach( $rawWindows as $w ) + { + if( !is_array($w) ) continue; + $from = isset($w['from']) ? trim($w['from']) : ''; + $to = isset($w['to']) ? trim($w['to']) : ''; + // Skip incomplete rows (so add-row-then-don't-fill doesn't pollute config). + if( $from === '' || $to === '' ) continue; + if( !preg_match('/^([01][0-9]|2[0-3]):[0-5][0-9]$/', $from) ) continue; + if( !preg_match('/^([01][0-9]|2[0-3]):[0-5][0-9]$/', $to) ) continue; + $entry = array('from' => $from, 'to' => $to); + $label = isset($w['label']) ? trim($w['label']) : ''; + if( $label !== '' ) $entry['label'] = $label; + $cleaned[] = $entry; + $quiethours_window_count++; + } + $data["quietHours"]["schedule"][$d] = array_values($cleaned); + } + $playtime_changed = true; // share the player-restart trigger below + $CHANGE_TXT = $CHANGE_TXT."
    • Quiet hours saved (".$quiethours_window_count." window(s), player restarting...)
    • "; + $change = 2; + } if( $data["shim"]["ledPin"]!=$_POST['ledPin'] && $_POST['ledPin']) { $data["shim"]["ledPin"]=$_POST['ledPin']; @@ -792,6 +831,107 @@
    +
    + Quiet hours +
      +
    • +

      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.

      +
    • +
    • +

      Status

      + Currently: '.($qh_enabled_state ? 'ENABLED' : 'DISABLED').'

      '; + ?> +

      Enable / disable quiet hours:

      + +
    • +
    • +

      Grace period (minutes)

      +

      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.

      + 'Monday', + 'tue' => 'Tuesday', + 'wed' => 'Wednesday', + 'thu' => 'Thursday', + 'fri' => 'Friday', + 'sat' => 'Saturday', + 'sun' => 'Sunday', + ); + foreach( $qh_day_labels as $key => $label ) { + $windows = isset($qh_schedule[$key]) && is_array($qh_schedule[$key]) ? $qh_schedule[$key] : array(); + echo '
      '; + echo ''.$label.''; + echo ''; + echo ''; + $idx = 0; + foreach( $windows as $w ) { + if( !is_array($w) ) continue; + $wFrom = htmlspecialchars(isset($w['from']) ? $w['from'] : ''); + $wTo = htmlspecialchars(isset($w['to']) ? $w['to'] : ''); + $wLabel = htmlspecialchars(isset($w['label']) ? $w['label'] : ''); + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + $idx++; + } + echo '
      FromToLabel (optional)
      '; + echo ''; + echo '
      '; + } + ?> +
    • +
    • + + +
    • +
    +
    + + +
    System settings
      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."
      • Playtime limit settings saved (player restarting...)
      • "; + $CHANGE_TXT = $CHANGE_TXT."
      • Playtime limit settings saved (live, no restart needed)
      • "; $change = 2; } if( $_POST['quiethours_save'] ) @@ -506,8 +506,8 @@ } $data["quietHours"]["schedule"][$d] = array_values($cleaned); } - $playtime_changed = true; // share the player-restart trigger below - $CHANGE_TXT = $CHANGE_TXT."
      • Quiet hours saved (".$quiethours_window_count." window(s), player restarting...)
      • "; + $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")."
    • "; + } + $data["telegram"]["chatId"] = $existing; $change=3; - $CHANGE_TXT=$CHANGE_TXT."
    • Telegram Chat ID generation finished...
    • "; } 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)