Browse Source

feat: add "scheduled" run mode to play playlists X times per day

Adds a third playlist run mode alongside single/loop: "scheduled" runs
the full playlist a configurable number of cycles per 24 hours, evenly
spread across the day's *active* hours. Still Sands periods are excluded
from the daily quota — the inter-cycle wait pauses while inside Still
Sands, so X full runs still happen during active time.

Backend:
- New `runs_per_day` parameter on `run_theta_rho_files` and
  `run_playlist`, plumbed through `/run_playlist` and the auto-play
  bootstrap.
- `state.auto_play_runs_per_day` (persisted) for the boot configuration.
- New `_wait_with_still_sands_monitor` helper consolidates the duplicate
  inter-pattern / inter-cycle pause loops and adds the option to skip
  Still Sands time when counting elapsed wait.
- Module-level daily counter resets at midnight in the user-configured
  Still Sands timezone.
- Drive-by fix: treat `run_mode="loop"` the same as `"indefinite"` in
  the playlist runner — auto-play defaulted to "loop" but only
  "indefinite" was actually looping.

Frontend:
- Playlists page run-mode toggle now cycles single → indefinite →
  scheduled, swapping the pause-time picker for a runs/day picker in
  scheduled mode.
- Settings → Auto-play gains a "Scheduled (X times per day)" run-mode
  option with a runs-per-day input.
Claude 2 months ago
parent
commit
1dae66ec1e

+ 1 - 1
frontend/src/lib/types.ts

@@ -22,7 +22,7 @@ export interface Playlist {
 
 
 export type SortOption = 'name' | 'date' | 'size' | 'favorites' | 'plays' | 'last_played'
 export type SortOption = 'name' | 'date' | 'size' | 'favorites' | 'plays' | 'last_played'
 export type PreExecution = 'none' | 'adaptive' | 'clear_from_in' | 'clear_from_out' | 'clear_sideway'
 export type PreExecution = 'none' | 'adaptive' | 'clear_from_in' | 'clear_from_out' | 'clear_sideway'
-export type RunMode = 'single' | 'indefinite'
+export type RunMode = 'single' | 'indefinite' | 'scheduled'
 
 
 export const preExecutionOptions: { value: PreExecution; label: string; description: string }[] = [
 export const preExecutionOptions: { value: PreExecution; label: string; description: string }[] = [
   { value: 'adaptive', label: 'Adaptive', description: 'Automatically picks the best clear direction based on where the ball is' },
   { value: 'adaptive', label: 'Adaptive', description: 'Automatically picks the best clear direction based on where the ball is' },

+ 97 - 45
frontend/src/pages/PlaylistsPage.tsx

@@ -93,7 +93,7 @@ export function PlaylistsPage() {
   // Playback settings - initialized from localStorage
   // Playback settings - initialized from localStorage
   const [runMode, setRunMode] = useState<RunMode>(() => {
   const [runMode, setRunMode] = useState<RunMode>(() => {
     const cached = localStorage.getItem('playlist-runMode')
     const cached = localStorage.getItem('playlist-runMode')
-    return (cached === 'single' || cached === 'indefinite') ? cached : 'single'
+    return (cached === 'single' || cached === 'indefinite' || cached === 'scheduled') ? cached : 'single'
   })
   })
   const [shuffle, setShuffle] = useState(() => {
   const [shuffle, setShuffle] = useState(() => {
     return localStorage.getItem('playlist-shuffle') === 'true'
     return localStorage.getItem('playlist-shuffle') === 'true'
@@ -106,6 +106,11 @@ export function PlaylistsPage() {
     const cached = localStorage.getItem('playlist-pauseUnit')
     const cached = localStorage.getItem('playlist-pauseUnit')
     return (cached === 'sec' || cached === 'min' || cached === 'hr') ? cached : 'min'
     return (cached === 'sec' || cached === 'min' || cached === 'hr') ? cached : 'min'
   })
   })
+  const [runsPerDay, setRunsPerDay] = useState<number>(() => {
+    const cached = localStorage.getItem('playlist-runsPerDay')
+    const parsed = cached ? Number(cached) : 3
+    return Number.isFinite(parsed) && parsed >= 1 ? parsed : 3
+  })
   const [clearPattern, setClearPattern] = useState<PreExecution>(() => {
   const [clearPattern, setClearPattern] = useState<PreExecution>(() => {
     const cached = localStorage.getItem('preExecution')
     const cached = localStorage.getItem('preExecution')
     return (cached as PreExecution) || 'adaptive'
     return (cached as PreExecution) || 'adaptive'
@@ -124,6 +129,9 @@ export function PlaylistsPage() {
   useEffect(() => {
   useEffect(() => {
     localStorage.setItem('playlist-pauseUnit', pauseUnit)
     localStorage.setItem('playlist-pauseUnit', pauseUnit)
   }, [pauseUnit])
   }, [pauseUnit])
+  useEffect(() => {
+    localStorage.setItem('playlist-runsPerDay', String(runsPerDay))
+  }, [runsPerDay])
   useEffect(() => {
   useEffect(() => {
     localStorage.setItem('preExecution', clearPattern)
     localStorage.setItem('preExecution', clearPattern)
   }, [clearPattern])
   }, [clearPattern])
@@ -458,10 +466,11 @@ export function PlaylistsPage() {
     try {
     try {
       await apiClient.post('/run_playlist', {
       await apiClient.post('/run_playlist', {
         playlist_name: selectedPlaylist,
         playlist_name: selectedPlaylist,
-        run_mode: runMode === 'indefinite' ? 'indefinite' : 'single',
+        run_mode: runMode,
         pause_time: getPauseTimeInSeconds(),
         pause_time: getPauseTimeInSeconds(),
         clear_pattern: clearPattern,
         clear_pattern: clearPattern,
         shuffle: shuffle,
         shuffle: shuffle,
+        runs_per_day: runMode === 'scheduled' ? Math.max(1, Math.floor(runsPerDay)) : 1,
       })
       })
       toast.success(`Started playlist: ${selectedPlaylist}`)
       toast.success(`Started playlist: ${selectedPlaylist}`)
       // Trigger Now Playing bar to open
       // Trigger Now Playing bar to open
@@ -778,58 +787,101 @@ export function PlaylistsPage() {
                       <span className="material-icons-outlined text-lg sm:text-xl">shuffle</span>
                       <span className="material-icons-outlined text-lg sm:text-xl">shuffle</span>
                     </button>
                     </button>
                     <button
                     <button
-                      onClick={() => setRunMode(runMode === 'indefinite' ? 'single' : 'indefinite')}
+                      onClick={() => {
+                        const order: RunMode[] = ['single', 'indefinite', 'scheduled']
+                        const next = order[(order.indexOf(runMode) + 1) % order.length]
+                        setRunMode(next)
+                      }}
                       className={`w-9 h-9 sm:w-10 sm:h-10 rounded-full flex items-center justify-center transition ${
                       className={`w-9 h-9 sm:w-10 sm:h-10 rounded-full flex items-center justify-center transition ${
-                        runMode === 'indefinite'
+                        runMode !== 'single'
                           ? 'text-primary bg-primary/10'
                           ? 'text-primary bg-primary/10'
                           : 'text-muted-foreground hover:bg-muted'
                           : 'text-muted-foreground hover:bg-muted'
                       }`}
                       }`}
-                      title={runMode === 'indefinite' ? 'Loop mode' : 'Play once mode'}
+                      title={
+                        runMode === 'scheduled'
+                          ? 'Scheduled — X runs per day (respects Still Sands)'
+                          : runMode === 'indefinite'
+                          ? 'Loop mode — repeat forever'
+                          : 'Play once mode'
+                      }
                     >
                     >
-                      <span className="material-icons-outlined text-lg sm:text-xl">repeat</span>
+                      <span className="material-icons-outlined text-lg sm:text-xl">
+                        {runMode === 'scheduled' ? 'today' : 'repeat'}
+                      </span>
                     </button>
                     </button>
                   </div>
                   </div>
 
 
-                  {/* Pause Time */}
-                  <div className="flex items-center px-2 sm:px-3 gap-2 sm:gap-3 border-r border-border">
-                    <span className="text-[10px] sm:text-xs font-semibold text-muted-foreground tracking-wider hidden sm:block">Pause</span>
-                    <div className="flex items-center gap-1">
-                      <Button
-                        variant="secondary"
-                        size="icon"
-                        className="w-7 h-7 sm:w-8 sm:h-8"
-                        onClick={() => {
-                          const step = pauseUnit === 'hr' ? 0.5 : 1
-                          setPauseTime(Math.max(0, pauseTime - step))
-                        }}
-                      >
-                        <span className="material-icons-outlined text-sm">remove</span>
-                      </Button>
-                      <button
-                        onClick={() => {
-                          const units: ('sec' | 'min' | 'hr')[] = ['sec', 'min', 'hr']
-                          const currentIndex = units.indexOf(pauseUnit)
-                          setPauseUnit(units[(currentIndex + 1) % units.length])
-                        }}
-                        className="relative flex items-center justify-center min-w-14 sm:min-w-16 px-1 text-xs sm:text-sm font-bold hover:text-primary transition"
-                        title="Click to change unit"
-                      >
-                        {pauseTime}{pauseUnit === 'sec' ? 's' : pauseUnit === 'min' ? 'm' : 'h'}
-                        <span className="material-icons-outlined text-xs opacity-50 scale-75 ml-0.5">swap_vert</span>
-                      </button>
-                      <Button
-                        variant="secondary"
-                        size="icon"
-                        className="w-7 h-7 sm:w-8 sm:h-8"
-                        onClick={() => {
-                          const step = pauseUnit === 'hr' ? 0.5 : 1
-                          setPauseTime(pauseTime + step)
-                        }}
-                      >
-                        <span className="material-icons-outlined text-sm">add</span>
-                      </Button>
+                  {runMode === 'scheduled' ? (
+                    /* Runs per day */
+                    <div className="flex items-center px-2 sm:px-3 gap-2 sm:gap-3 border-r border-border">
+                      <span className="text-[10px] sm:text-xs font-semibold text-muted-foreground tracking-wider hidden sm:block">Runs/day</span>
+                      <div className="flex items-center gap-1">
+                        <Button
+                          variant="secondary"
+                          size="icon"
+                          className="w-7 h-7 sm:w-8 sm:h-8"
+                          onClick={() => setRunsPerDay(Math.max(1, runsPerDay - 1))}
+                        >
+                          <span className="material-icons-outlined text-sm">remove</span>
+                        </Button>
+                        <span
+                          className="relative flex items-center justify-center min-w-14 sm:min-w-16 px-1 text-xs sm:text-sm font-bold"
+                          title="Number of full playlist cycles per day. Still Sands periods are excluded from the daily quota."
+                        >
+                          {runsPerDay}/day
+                        </span>
+                        <Button
+                          variant="secondary"
+                          size="icon"
+                          className="w-7 h-7 sm:w-8 sm:h-8"
+                          onClick={() => setRunsPerDay(Math.min(96, runsPerDay + 1))}
+                        >
+                          <span className="material-icons-outlined text-sm">add</span>
+                        </Button>
+                      </div>
                     </div>
                     </div>
-                  </div>
+                  ) : (
+                    /* Pause Time */
+                    <div className="flex items-center px-2 sm:px-3 gap-2 sm:gap-3 border-r border-border">
+                      <span className="text-[10px] sm:text-xs font-semibold text-muted-foreground tracking-wider hidden sm:block">Pause</span>
+                      <div className="flex items-center gap-1">
+                        <Button
+                          variant="secondary"
+                          size="icon"
+                          className="w-7 h-7 sm:w-8 sm:h-8"
+                          onClick={() => {
+                            const step = pauseUnit === 'hr' ? 0.5 : 1
+                            setPauseTime(Math.max(0, pauseTime - step))
+                          }}
+                        >
+                          <span className="material-icons-outlined text-sm">remove</span>
+                        </Button>
+                        <button
+                          onClick={() => {
+                            const units: ('sec' | 'min' | 'hr')[] = ['sec', 'min', 'hr']
+                            const currentIndex = units.indexOf(pauseUnit)
+                            setPauseUnit(units[(currentIndex + 1) % units.length])
+                          }}
+                          className="relative flex items-center justify-center min-w-14 sm:min-w-16 px-1 text-xs sm:text-sm font-bold hover:text-primary transition"
+                          title="Click to change unit"
+                        >
+                          {pauseTime}{pauseUnit === 'sec' ? 's' : pauseUnit === 'min' ? 'm' : 'h'}
+                          <span className="material-icons-outlined text-xs opacity-50 scale-75 ml-0.5">swap_vert</span>
+                        </button>
+                        <Button
+                          variant="secondary"
+                          size="icon"
+                          className="w-7 h-7 sm:w-8 sm:h-8"
+                          onClick={() => {
+                            const step = pauseUnit === 'hr' ? 0.5 : 1
+                            setPauseTime(pauseTime + step)
+                          }}
+                        >
+                          <span className="material-icons-outlined text-sm">add</span>
+                        </Button>
+                      </div>
+                    </div>
+                  )}
 
 
                   {/* Clear Pattern Dropdown */}
                   {/* Clear Pattern Dropdown */}
                   <div className="flex items-center px-1 sm:px-2">
                   <div className="flex items-center px-1 sm:px-2">

+ 61 - 35
frontend/src/pages/SettingsPage.tsx

@@ -74,10 +74,11 @@ interface StillSandsSettings {
 interface AutoPlaySettings {
 interface AutoPlaySettings {
   enabled: boolean
   enabled: boolean
   playlist: string
   playlist: string
-  run_mode: 'single' | 'loop'
+  run_mode: 'single' | 'loop' | 'scheduled'
   pause_time: number
   pause_time: number
   clear_pattern: string
   clear_pattern: string
   shuffle: boolean
   shuffle: boolean
+  runs_per_day: number
 }
 }
 
 
 interface LedConfig {
 interface LedConfig {
@@ -137,6 +138,7 @@ export function SettingsPage() {
     pause_time: 5,
     pause_time: 5,
     clear_pattern: 'adaptive',
     clear_pattern: 'adaptive',
     shuffle: false,
     shuffle: false,
+    runs_per_day: 3,
   })
   })
   const [autoPlayPauseUnit, setAutoPlayPauseUnit] = useState<'sec' | 'min' | 'hr'>('min')
   const [autoPlayPauseUnit, setAutoPlayPauseUnit] = useState<'sec' | 'min' | 'hr'>('min')
   const [autoPlayPauseValue, setAutoPlayPauseValue] = useState(5)
   const [autoPlayPauseValue, setAutoPlayPauseValue] = useState(5)
@@ -385,6 +387,7 @@ export function SettingsPage() {
           pause_time: pauseSeconds,
           pause_time: pauseSeconds,
           clear_pattern: data.auto_play.clear_pattern || 'adaptive',
           clear_pattern: data.auto_play.clear_pattern || 'adaptive',
           shuffle: data.auto_play.shuffle || false,
           shuffle: data.auto_play.shuffle || false,
+          runs_per_day: Math.max(1, data.auto_play.runs_per_day || 3),
         })
         })
       }
       }
       // Set still sands settings
       // Set still sands settings
@@ -1863,7 +1866,7 @@ export function SettingsPage() {
                       onValueChange={(value) =>
                       onValueChange={(value) =>
                         setAutoPlaySettings({
                         setAutoPlaySettings({
                           ...autoPlaySettings,
                           ...autoPlaySettings,
-                          run_mode: value as 'single' | 'loop',
+                          run_mode: value as 'single' | 'loop' | 'scheduled',
                         })
                         })
                       }
                       }
                     >
                     >
@@ -1873,49 +1876,72 @@ export function SettingsPage() {
                       <SelectContent>
                       <SelectContent>
                         <SelectItem value="single">Single (play once)</SelectItem>
                         <SelectItem value="single">Single (play once)</SelectItem>
                         <SelectItem value="loop">Loop (repeat forever)</SelectItem>
                         <SelectItem value="loop">Loop (repeat forever)</SelectItem>
+                        <SelectItem value="scheduled">Scheduled (X times per day)</SelectItem>
                       </SelectContent>
                       </SelectContent>
                     </Select>
                     </Select>
                   </div>
                   </div>
-                  <div className="space-y-3">
-                    <Label>Pause Between Patterns</Label>
-                    <div className="flex gap-2">
+                  {autoPlaySettings.run_mode === 'scheduled' ? (
+                    <div className="space-y-3">
+                      <Label>Runs Per Day</Label>
                       <Input
                       <Input
-                        type="text"
-                        inputMode="numeric"
-                        value={autoPlayPauseInput}
+                        type="number"
+                        min={1}
+                        max={96}
+                        step={1}
+                        value={autoPlaySettings.runs_per_day}
                         onChange={(e) => {
                         onChange={(e) => {
-                          const val = e.target.value.replace(/[^0-9]/g, '')
-                          setAutoPlayPauseInput(val)
+                          const num = Math.max(1, Math.min(96, parseInt(e.target.value) || 1))
+                          setAutoPlaySettings({ ...autoPlaySettings, runs_per_day: num })
                         }}
                         }}
-                        onBlur={() => {
-                          const num = Math.max(0, parseInt(autoPlayPauseInput) || 0)
-                          setAutoPlayPauseValue(num)
-                          setAutoPlayPauseInput(String(num))
-                        }}
-                        onKeyDown={(e) => {
-                          if (e.key === 'Enter') {
+                        className="w-24"
+                      />
+                      <p className="text-xs text-muted-foreground">
+                        Spread this many full playlist cycles evenly across the day's active hours.
+                        Still Sands periods don't count toward the daily quota.
+                      </p>
+                    </div>
+                  ) : (
+                    <div className="space-y-3">
+                      <Label>Pause Between Patterns</Label>
+                      <div className="flex gap-2">
+                        <Input
+                          type="text"
+                          inputMode="numeric"
+                          value={autoPlayPauseInput}
+                          onChange={(e) => {
+                            const val = e.target.value.replace(/[^0-9]/g, '')
+                            setAutoPlayPauseInput(val)
+                          }}
+                          onBlur={() => {
                             const num = Math.max(0, parseInt(autoPlayPauseInput) || 0)
                             const num = Math.max(0, parseInt(autoPlayPauseInput) || 0)
                             setAutoPlayPauseValue(num)
                             setAutoPlayPauseValue(num)
                             setAutoPlayPauseInput(String(num))
                             setAutoPlayPauseInput(String(num))
-                          }
-                        }}
-                        className="w-20"
-                      />
-                      <Select
-                        value={autoPlayPauseUnit}
-                        onValueChange={(v) => setAutoPlayPauseUnit(v as 'sec' | 'min' | 'hr')}
-                      >
-                        <SelectTrigger className="w-20">
-                          <SelectValue />
-                        </SelectTrigger>
-                        <SelectContent>
-                          <SelectItem value="sec">sec</SelectItem>
-                          <SelectItem value="min">min</SelectItem>
-                          <SelectItem value="hr">hr</SelectItem>
-                        </SelectContent>
-                      </Select>
+                          }}
+                          onKeyDown={(e) => {
+                            if (e.key === 'Enter') {
+                              const num = Math.max(0, parseInt(autoPlayPauseInput) || 0)
+                              setAutoPlayPauseValue(num)
+                              setAutoPlayPauseInput(String(num))
+                            }
+                          }}
+                          className="w-20"
+                        />
+                        <Select
+                          value={autoPlayPauseUnit}
+                          onValueChange={(v) => setAutoPlayPauseUnit(v as 'sec' | 'min' | 'hr')}
+                        >
+                          <SelectTrigger className="w-20">
+                            <SelectValue />
+                          </SelectTrigger>
+                          <SelectContent>
+                            <SelectItem value="sec">sec</SelectItem>
+                            <SelectItem value="min">min</SelectItem>
+                            <SelectItem value="hr">hr</SelectItem>
+                          </SelectContent>
+                        </Select>
+                      </div>
                     </div>
                     </div>
-                  </div>
+                  )}
                 </div>
                 </div>
 
 
                 <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
                 <div className="grid grid-cols-1 md:grid-cols-2 gap-4">

+ 11 - 3
main.py

@@ -144,7 +144,8 @@ async def lifespan(app: FastAPI):
                                 pause_time=state.auto_play_pause_time,
                                 pause_time=state.auto_play_pause_time,
                                 clear_pattern=state.auto_play_clear_pattern,
                                 clear_pattern=state.auto_play_clear_pattern,
                                 run_mode=state.auto_play_run_mode,
                                 run_mode=state.auto_play_run_mode,
-                                shuffle=state.auto_play_shuffle
+                                shuffle=state.auto_play_shuffle,
+                                runs_per_day=state.auto_play_runs_per_day,
                             ))
                             ))
                     except Exception as e:
                     except Exception as e:
                         logger.error(f"Failed to auto-play playlist: {str(e)}")
                         logger.error(f"Failed to auto-play playlist: {str(e)}")
@@ -418,6 +419,7 @@ class PlaylistRequest(BaseModel):
     clear_pattern: Optional[str] = None
     clear_pattern: Optional[str] = None
     run_mode: str = "single"
     run_mode: str = "single"
     shuffle: bool = False
     shuffle: bool = False
+    runs_per_day: int = 1  # only used when run_mode == "scheduled"
 
 
 class PlaylistRunRequest(BaseModel):
 class PlaylistRunRequest(BaseModel):
     playlist_name: str
     playlist_name: str
@@ -425,6 +427,7 @@ class PlaylistRunRequest(BaseModel):
     clear_pattern: Optional[str] = None
     clear_pattern: Optional[str] = None
     run_mode: Optional[str] = "single"
     run_mode: Optional[str] = "single"
     shuffle: Optional[bool] = False
     shuffle: Optional[bool] = False
+    runs_per_day: Optional[int] = 1
     start_time: Optional[str] = None
     start_time: Optional[str] = None
     end_time: Optional[str] = None
     end_time: Optional[str] = None
 
 
@@ -480,6 +483,7 @@ class AutoPlaySettingsUpdate(BaseModel):
     pause_time: Optional[float] = None
     pause_time: Optional[float] = None
     clear_pattern: Optional[str] = None
     clear_pattern: Optional[str] = None
     shuffle: Optional[bool] = None
     shuffle: Optional[bool] = None
+    runs_per_day: Optional[int] = None
 
 
 class ScheduledPauseSettingsUpdate(BaseModel):
 class ScheduledPauseSettingsUpdate(BaseModel):
     enabled: Optional[bool] = None
     enabled: Optional[bool] = None
@@ -752,7 +756,8 @@ async def get_all_settings():
             "run_mode": state.auto_play_run_mode,
             "run_mode": state.auto_play_run_mode,
             "pause_time": state.auto_play_pause_time,
             "pause_time": state.auto_play_pause_time,
             "clear_pattern": state.auto_play_clear_pattern,
             "clear_pattern": state.auto_play_clear_pattern,
-            "shuffle": state.auto_play_shuffle
+            "shuffle": state.auto_play_shuffle,
+            "runs_per_day": state.auto_play_runs_per_day
         },
         },
         "scheduled_pause": {
         "scheduled_pause": {
             "enabled": state.scheduled_pause_enabled,
             "enabled": state.scheduled_pause_enabled,
@@ -934,6 +939,8 @@ async def update_settings(settings_update: SettingsUpdate):
             state.auto_play_clear_pattern = ap.clear_pattern
             state.auto_play_clear_pattern = ap.clear_pattern
         if ap.shuffle is not None:
         if ap.shuffle is not None:
             state.auto_play_shuffle = ap.shuffle
             state.auto_play_shuffle = ap.shuffle
+        if ap.runs_per_day is not None:
+            state.auto_play_runs_per_day = max(1, int(ap.runs_per_day))
         updated_categories.append("auto_play")
         updated_categories.append("auto_play")
 
 
     # Scheduled pause (Still Sands) settings
     # Scheduled pause (Still Sands) settings
@@ -2557,7 +2564,8 @@ async def run_playlist_endpoint(request: PlaylistRequest):
             pause_time=request.pause_time,
             pause_time=request.pause_time,
             clear_pattern=request.clear_pattern,
             clear_pattern=request.clear_pattern,
             run_mode=request.run_mode,
             run_mode=request.run_mode,
-            shuffle=request.shuffle
+            shuffle=request.shuffle,
+            runs_per_day=max(1, int(request.runs_per_day or 1)),
         )
         )
         if not success:
         if not success:
             raise HTTPException(status_code=409, detail=message)
             raise HTTPException(status_code=409, detail=message)

+ 209 - 63
modules/core/pattern_manager.py

@@ -4,7 +4,7 @@ import threading
 import time
 import time
 import random
 import random
 import logging
 import logging
-from datetime import datetime, time as datetime_time
+from datetime import datetime, time as datetime_time, timedelta
 from tqdm import tqdm
 from tqdm import tqdm
 from modules.connection import connection_manager
 from modules.connection import connection_manager
 from modules.core.state import state
 from modules.core.state import state
@@ -320,6 +320,105 @@ def is_in_scheduled_pause_period():
     return False
     return False
 
 
 
 
+def _scheduled_pause_seconds_in_window(start_dt: datetime, end_dt: datetime) -> float:
+    """Compute total still-sands seconds within [start_dt, end_dt) using configured slots.
+
+    Both datetimes are expected in the user-configured timezone (or naive local time).
+    Walks all configured slots and sums their overlap with the requested window,
+    handling slots that span midnight and weekday filters.
+    """
+    if not state.scheduled_pause_enabled or not state.scheduled_pause_time_slots:
+        return 0.0
+    if end_dt <= start_dt:
+        return 0.0
+
+    weekday_names = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']
+
+    def slot_applies_on(weekday_idx: int, slot: dict) -> bool:
+        days_setting = slot.get('days', 'daily')
+        if days_setting == 'daily':
+            return True
+        if days_setting == 'weekdays':
+            return weekday_idx < 5
+        if days_setting == 'weekends':
+            return weekday_idx >= 5
+        if days_setting == 'custom':
+            return weekday_names[weekday_idx] in slot.get('custom_days', [])
+        return False
+
+    # Build per-day intervals (as datetimes) for each calendar day touched by the window.
+    # Start one day earlier so that slots from the prior day that span midnight into our
+    # window (e.g. 22:00–06:00 starting yesterday) are included.
+    intervals = []
+    day_cursor = start_dt.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=1)
+    last_day = end_dt.replace(hour=0, minute=0, second=0, microsecond=0)
+    while day_cursor <= last_day:
+        weekday_idx = day_cursor.weekday()
+        for slot in state.scheduled_pause_time_slots:
+            try:
+                start_t = datetime_time.fromisoformat(slot['start_time'])
+                end_t = datetime_time.fromisoformat(slot['end_time'])
+            except (ValueError, KeyError):
+                continue
+            if not slot_applies_on(weekday_idx, slot):
+                continue
+            slot_start = day_cursor.replace(hour=start_t.hour, minute=start_t.minute, second=start_t.second)
+            if start_t <= end_t:
+                slot_end = day_cursor.replace(hour=end_t.hour, minute=end_t.minute, second=end_t.second)
+                intervals.append((slot_start, slot_end))
+            else:
+                # Spans midnight: split into two intervals
+                end_of_day = day_cursor + timedelta(days=1)
+                intervals.append((slot_start, end_of_day))
+                next_day = day_cursor + timedelta(days=1)
+                intervals.append((next_day, next_day.replace(hour=end_t.hour, minute=end_t.minute, second=end_t.second)))
+        day_cursor += timedelta(days=1)
+
+    # Sum overlap with [start_dt, end_dt), merging is unnecessary because we clip per-interval
+    # but we should merge to avoid double counting overlapping slots.
+    intervals = [(max(s, start_dt), min(e, end_dt)) for s, e in intervals]
+    intervals = [(s, e) for s, e in intervals if e > s]
+    intervals.sort()
+    merged = []
+    for s, e in intervals:
+        if merged and s <= merged[-1][1]:
+            merged[-1] = (merged[-1][0], max(merged[-1][1], e))
+        else:
+            merged.append((s, e))
+    return sum((e - s).total_seconds() for s, e in merged)
+
+
+def _now_in_user_tz() -> datetime:
+    """Return current datetime in the user-configured timezone (Still Sands TZ), or system local time."""
+    tz_info = _get_timezone()
+    if tz_info:
+        return datetime.now(tz_info)
+    return datetime.now()
+
+
+# --- Scheduled run-mode tracker -------------------------------------------------
+# Tracks how many full playlist cycles have completed today (per user TZ).
+# Resets automatically when the calendar day rolls over.
+_scheduled_runs_completed_today: int = 0
+_scheduled_runs_day_token: Optional[str] = None
+
+
+def _refresh_scheduled_day_token() -> str:
+    """Reset the daily run counter if the calendar day has changed. Returns today's token."""
+    global _scheduled_runs_completed_today, _scheduled_runs_day_token
+    today_token = _now_in_user_tz().strftime("%Y-%m-%d")
+    if today_token != _scheduled_runs_day_token:
+        _scheduled_runs_day_token = today_token
+        _scheduled_runs_completed_today = 0
+    return today_token
+
+
+def get_scheduled_runs_completed_today() -> int:
+    """Number of full playlist cycles that have completed today (auto-resets at midnight)."""
+    _refresh_scheduled_day_token()
+    return _scheduled_runs_completed_today
+
+
 async def check_table_is_idle() -> bool:
 async def check_table_is_idle() -> bool:
     """
     """
     Check if the table is currently idle by querying actual machine status.
     Check if the table is currently idle by querying actual machine status.
@@ -1515,11 +1614,66 @@ async def run_theta_rho_file(file_path, is_playlist=False, clear_pattern=None, c
             logger.info("Pattern execution completed, maintaining state for playlist")
             logger.info("Pattern execution completed, maintaining state for playlist")
             
             
 
 
-async def run_theta_rho_files(file_paths, pause_time=0, clear_pattern=None, run_mode="single", shuffle=False):
+async def _wait_with_still_sands_monitor(total_seconds: float, count_still_sands: bool = True):
+    """Sleep for `total_seconds` while updating LED state across Still Sands transitions.
+
+    If count_still_sands is False, time spent inside a Still Sands period does NOT count
+    toward the elapsed total — useful for the "scheduled" run mode, which targets X runs
+    per active day and excludes Still Sands from its quota.
+
+    Returns early on stop_requested or skip_requested.
+    """
+    if total_seconds <= 0:
+        return
+    state.original_pause_time = total_seconds
+    pause_start = time.time()
+    elapsed = 0.0
+    last_tick = pause_start
+    was_in_still_sands = is_in_scheduled_pause_period() and state.scheduled_pause_control_wled
+    while elapsed < total_seconds:
+        state.pause_time_remaining = max(0.0, total_seconds - elapsed)
+        if state.stop_requested or state.skip_requested:
+            if state.stop_requested:
+                logger.info("Pause interrupted by stop request")
+            else:
+                logger.info("Pause interrupted by skip request")
+            break
+
+        in_still_sands_full = is_in_scheduled_pause_period()
+        in_still_sands_wled = in_still_sands_full and state.scheduled_pause_control_wled
+        if in_still_sands_wled and not was_in_still_sands:
+            logger.info("Still Sands period started during pause, turning off LEDs")
+            if state.led_controller:
+                await state.led_controller.set_power_async(0)
+        elif not in_still_sands_wled and was_in_still_sands:
+            logger.info("Still Sands period ended during pause, restoring idle LED effect")
+            await start_idle_led_timeout(check_still_sands=False)
+        was_in_still_sands = in_still_sands_wled
+
+        await asyncio.sleep(1)
+        now = time.time()
+        delta = now - last_tick
+        last_tick = now
+        # Skip elapsed time accumulation while in Still Sands when caller asked us to.
+        if count_still_sands or not in_still_sands_full:
+            elapsed += delta
+
+    state.pause_time_remaining = 0
+    state.original_pause_time = None
+
+
+async def run_theta_rho_files(file_paths, pause_time=0, clear_pattern=None, run_mode="single", shuffle=False, runs_per_day=1):
     """Run multiple .thr files in sequence with options.
     """Run multiple .thr files in sequence with options.
 
 
     The playlist now stores only main patterns. Clear patterns are executed dynamically
     The playlist now stores only main patterns. Clear patterns are executed dynamically
     before each main pattern based on the clear_pattern option.
     before each main pattern based on the clear_pattern option.
+
+    run_mode:
+        - "single": play the playlist once, then stop
+        - "loop"/"indefinite": play the playlist forever, with `pause_time` between cycles
+        - "scheduled": play the full playlist `runs_per_day` times within a 24-hour day,
+          spreading runs evenly across the day's *active* hours (Still Sands periods are
+          excluded from the quota — the gap between cycles pauses while inside Still Sands).
     """
     """
     state.stop_requested = False
     state.stop_requested = False
 
 
@@ -1636,34 +1790,7 @@ async def run_theta_rho_files(file_paths, pause_time=0, clear_pattern=None, run_
                     state.current_playing_file = None
                     state.current_playing_file = None
                     # Trigger idle LED state during pause between patterns
                     # Trigger idle LED state during pause between patterns
                     await start_idle_led_timeout(check_still_sands=True)
                     await start_idle_led_timeout(check_still_sands=True)
-                    state.original_pause_time = pause_time
-                    pause_start = time.time()
-                    # Track Still Sands state for edge detection during long pauses
-                    was_in_still_sands = is_in_scheduled_pause_period() and state.scheduled_pause_control_wled
-                    while time.time() - pause_start < pause_time:
-                        state.pause_time_remaining = pause_start + pause_time - time.time()
-                        if state.skip_requested or state.stop_requested:
-                            if state.stop_requested:
-                                logger.info("Pause interrupted by stop request")
-                            else:
-                                logger.info("Pause interrupted by skip request")
-                            break
-                        # Monitor Still Sands transitions during pause
-                        in_still_sands = is_in_scheduled_pause_period() and state.scheduled_pause_control_wled
-                        if in_still_sands and not was_in_still_sands:
-                            # Entering Still Sands period — turn off LEDs
-                            logger.info("Still Sands period started during pause, turning off LEDs")
-                            if state.led_controller:
-                                await state.led_controller.set_power_async(0)
-                        elif not in_still_sands and was_in_still_sands:
-                            # Leaving Still Sands period — restore idle effect
-                            logger.info("Still Sands period ended during pause, restoring idle LED effect")
-                            await start_idle_led_timeout(check_still_sands=False)
-                        was_in_still_sands = in_still_sands
-                        await asyncio.sleep(1)
-                    # Clear both pause state vars immediately (so UI updates right away)
-                    state.pause_time_remaining = 0
-                    state.original_pause_time = None
+                    await _wait_with_still_sands_monitor(pause_time, count_still_sands=True)
 
 
                 # Auto-home after pause time, before next clear pattern starts
                 # Auto-home after pause time, before next clear pattern starts
                 # Only home if there's a next pattern and we haven't been stopped
                 # Only home if there's a next pattern and we haven't been stopped
@@ -1685,41 +1812,60 @@ async def run_theta_rho_files(file_paths, pause_time=0, clear_pattern=None, run_
                 state.skip_requested = False
                 state.skip_requested = False
                 idx += 1
                 idx += 1
 
 
-            if run_mode == "indefinite":
-                logger.info("Playlist completed. Restarting as per 'indefinite' run mode")
-                if pause_time > 0:
-                    # Clear current_playing_file to report "idle" state to MQTT/HA during pause
+            if run_mode in ("indefinite", "loop"):
+                logger.info(f"Playlist completed. Restarting as per '{run_mode}' run mode")
+                if pause_time > 0 and not state.stop_requested:
                     state.current_playing_file = None
                     state.current_playing_file = None
-                    # Trigger idle LED state during pause between playlist cycles
                     await start_idle_led_timeout(check_still_sands=True)
                     await start_idle_led_timeout(check_still_sands=True)
-                    state.original_pause_time = pause_time
-                    pause_start = time.time()
-                    # Track Still Sands state for edge detection during long pauses
-                    was_in_still_sands = is_in_scheduled_pause_period() and state.scheduled_pause_control_wled
-                    while time.time() - pause_start < pause_time:
-                        state.pause_time_remaining = pause_start + pause_time - time.time()
-                        if state.skip_requested or state.stop_requested:
-                            if state.stop_requested:
-                                logger.info("Pause interrupted by stop request")
-                            else:
-                                logger.info("Pause interrupted by skip request")
-                            break
-                        # Monitor Still Sands transitions during pause
-                        in_still_sands = is_in_scheduled_pause_period() and state.scheduled_pause_control_wled
-                        if in_still_sands and not was_in_still_sands:
-                            # Entering Still Sands period — turn off LEDs
-                            logger.info("Still Sands period started during pause, turning off LEDs")
-                            if state.led_controller:
-                                await state.led_controller.set_power_async(0)
-                        elif not in_still_sands and was_in_still_sands:
-                            # Leaving Still Sands period — restore idle effect
-                            logger.info("Still Sands period ended during pause, restoring idle LED effect")
-                            await start_idle_led_timeout(check_still_sands=False)
-                        was_in_still_sands = in_still_sands
-                        await asyncio.sleep(1)
-                    # Clear both pause state vars immediately (so UI updates right away)
-                    state.pause_time_remaining = 0
-                    state.original_pause_time = None
+                    await _wait_with_still_sands_monitor(pause_time, count_still_sands=True)
+                continue
+            elif run_mode == "scheduled":
+                # "Run X times per day" mode. Distribute runs evenly across the day's
+                # *active* hours (Still Sands time is excluded from the daily quota).
+                global _scheduled_runs_completed_today
+                _refresh_scheduled_day_token()
+                _scheduled_runs_completed_today += 1
+                target_runs = max(1, int(runs_per_day or 1))
+                logger.info(f"Scheduled mode: completed run {_scheduled_runs_completed_today}/{target_runs} for today")
+
+                if state.stop_requested:
+                    break
+
+                # Compute how long to wait before the next cycle.
+                now_user = _now_in_user_tz()
+                tomorrow_midnight = (now_user + timedelta(days=1)).replace(
+                    hour=0, minute=0, second=0, microsecond=0
+                )
+                seconds_until_midnight = max(0.0, (tomorrow_midnight - now_user).total_seconds())
+                still_sands_until_midnight = _scheduled_pause_seconds_in_window(now_user, tomorrow_midnight)
+                active_seconds_until_midnight = max(0.0, seconds_until_midnight - still_sands_until_midnight)
+
+                runs_remaining_today = target_runs - _scheduled_runs_completed_today
+
+                if runs_remaining_today <= 0:
+                    # All target runs done today: idle until tomorrow's midnight, then resume.
+                    logger.info(
+                        f"Scheduled mode: daily quota of {target_runs} runs reached. "
+                        f"Sleeping {seconds_until_midnight/3600:.2f}h until next day."
+                    )
+                    state.current_playing_file = None
+                    await start_idle_led_timeout(check_still_sands=True)
+                    # Count Still Sands toward this end-of-day wait — we just need to reach midnight.
+                    await _wait_with_still_sands_monitor(seconds_until_midnight, count_still_sands=True)
+                    # Day rolled over: refresh resets the counter on next loop iteration.
+                    _refresh_scheduled_day_token()
+                else:
+                    # Spread the remaining runs evenly across the remaining active time.
+                    # Wait this many *active* seconds before starting the next cycle.
+                    target_active_wait = active_seconds_until_midnight / runs_remaining_today
+                    logger.info(
+                        f"Scheduled mode: {runs_remaining_today} runs remaining, "
+                        f"{active_seconds_until_midnight/3600:.2f}h active time left. "
+                        f"Waiting ~{target_active_wait/60:.1f}m of active time before next run."
+                    )
+                    state.current_playing_file = None
+                    await start_idle_led_timeout(check_still_sands=True)
+                    await _wait_with_still_sands_monitor(target_active_wait, count_still_sands=False)
                 continue
                 continue
             else:
             else:
                 logger.info("Playlist completed")
                 logger.info("Playlist completed")

+ 10 - 3
modules/core/playlist_manager.py

@@ -138,8 +138,13 @@ async def cancel_current_playlist():
             logger.warning(f"Error while cancelling playlist task: {e}")
             logger.warning(f"Error while cancelling playlist task: {e}")
         _current_playlist_task = None
         _current_playlist_task = None
 
 
-async def run_playlist(playlist_name, pause_time=0, clear_pattern=None, run_mode="single", shuffle=False):
-    """Run a playlist with the given options."""
+async def run_playlist(playlist_name, pause_time=0, clear_pattern=None, run_mode="single", shuffle=False, runs_per_day=1):
+    """Run a playlist with the given options.
+
+    runs_per_day applies only when run_mode == "scheduled": the playlist will be played
+    that many full cycles per 24 hours, evenly spread across the day's active hours
+    (Still Sands periods are excluded from the daily quota).
+    """
     global _current_playlist_task
     global _current_playlist_task
 
 
     # Cancel any existing playlist task first
     # Cancel any existing playlist task first
@@ -163,7 +168,8 @@ async def run_playlist(playlist_name, pause_time=0, clear_pattern=None, run_mode
         return False, "Playlist is empty"
         return False, "Playlist is empty"
 
 
     try:
     try:
-        logger.info(f"Starting playlist '{playlist_name}' with mode={run_mode}, shuffle={shuffle}")
+        extra = f", runs_per_day={runs_per_day}" if run_mode == "scheduled" else ""
+        logger.info(f"Starting playlist '{playlist_name}' with mode={run_mode}, shuffle={shuffle}{extra}")
         # Set ALL playlist state variables BEFORE creating the async task.
         # Set ALL playlist state variables BEFORE creating the async task.
         # This ensures state is correct even if the task doesn't start immediately
         # This ensures state is correct even if the task doesn't start immediately
         # (important for TestClient which may cancel background tasks).
         # (important for TestClient which may cancel background tasks).
@@ -178,6 +184,7 @@ async def run_playlist(playlist_name, pause_time=0, clear_pattern=None, run_mode
                 clear_pattern=clear_pattern,
                 clear_pattern=clear_pattern,
                 run_mode=run_mode,
                 run_mode=run_mode,
                 shuffle=shuffle,
                 shuffle=shuffle,
+                runs_per_day=runs_per_day,
             )
             )
         )
         )
         return True, f"Playlist '{playlist_name}' is now running."
         return True, f"Playlist '{playlist_name}' is now running."

+ 4 - 1
modules/core/state.py

@@ -151,10 +151,11 @@ class AppState:
         # auto_play mode settings
         # auto_play mode settings
         self.auto_play_enabled = False
         self.auto_play_enabled = False
         self.auto_play_playlist = None  # Playlist to auto-play in auto_play mode
         self.auto_play_playlist = None  # Playlist to auto-play in auto_play mode
-        self.auto_play_run_mode = "loop"  # "single" or "loop"
+        self.auto_play_run_mode = "loop"  # "single", "loop", or "scheduled"
         self.auto_play_pause_time = 5.0  # Pause between patterns in seconds
         self.auto_play_pause_time = 5.0  # Pause between patterns in seconds
         self.auto_play_clear_pattern = "adaptive"  # Clear pattern option
         self.auto_play_clear_pattern = "adaptive"  # Clear pattern option
         self.auto_play_shuffle = False  # Shuffle playlist
         self.auto_play_shuffle = False  # Shuffle playlist
+        self.auto_play_runs_per_day = 1  # Number of full playlist cycles per day (for "scheduled" run mode)
 
 
         # Still Sands settings
         # Still Sands settings
         self.scheduled_pause_enabled = False
         self.scheduled_pause_enabled = False
@@ -531,6 +532,7 @@ class AppState:
             "auto_play_pause_time": self.auto_play_pause_time,
             "auto_play_pause_time": self.auto_play_pause_time,
             "auto_play_clear_pattern": self.auto_play_clear_pattern,
             "auto_play_clear_pattern": self.auto_play_clear_pattern,
             "auto_play_shuffle": self.auto_play_shuffle,
             "auto_play_shuffle": self.auto_play_shuffle,
+            "auto_play_runs_per_day": self.auto_play_runs_per_day,
             "scheduled_pause_enabled": self.scheduled_pause_enabled,
             "scheduled_pause_enabled": self.scheduled_pause_enabled,
             "scheduled_pause_time_slots": self.scheduled_pause_time_slots,
             "scheduled_pause_time_slots": self.scheduled_pause_time_slots,
             "scheduled_pause_control_wled": self.scheduled_pause_control_wled,
             "scheduled_pause_control_wled": self.scheduled_pause_control_wled,
@@ -652,6 +654,7 @@ class AppState:
         self.auto_play_pause_time = data.get("auto_play_pause_time", 5.0)
         self.auto_play_pause_time = data.get("auto_play_pause_time", 5.0)
         self.auto_play_clear_pattern = data.get("auto_play_clear_pattern", "adaptive")
         self.auto_play_clear_pattern = data.get("auto_play_clear_pattern", "adaptive")
         self.auto_play_shuffle = data.get("auto_play_shuffle", False)
         self.auto_play_shuffle = data.get("auto_play_shuffle", False)
+        self.auto_play_runs_per_day = data.get("auto_play_runs_per_day", 1)
         self.scheduled_pause_enabled = data.get("scheduled_pause_enabled", False)
         self.scheduled_pause_enabled = data.get("scheduled_pause_enabled", False)
         self.scheduled_pause_time_slots = data.get("scheduled_pause_time_slots", [])
         self.scheduled_pause_time_slots = data.get("scheduled_pause_time_slots", [])
         self.scheduled_pause_control_wled = data.get("scheduled_pause_control_wled", False)
         self.scheduled_pause_control_wled = data.get("scheduled_pause_control_wled", False)