#!/bin/bash
# sleepytime: a Spotify-driven wind-down routine, scheduled with launchd.
#
#   00:15  switch Spotify to the Wind Down playlist
#   00:45  switch Spotify to the Bed Time playlist
#   01:00  fade Spotify out and pause
#
# Usage:
#   sleepytime on                 install and load the launchd jobs
#   sleepytime off                unload and remove the launchd jobs
#   sleepytime status             show whether the jobs are loaded
#   sleepytime test [CUE]         run a cue now, through launchd (default: winddown)
#   sleepytime cue CUE [--force]  run a cue directly (what launchd calls)
#
# CUE is one of: winddown, bedtime, fadeout.

set -euo pipefail

# See README.md for how to get these playlist URIs
WINDDOWN_PLAYLIST="spotify:playlist:37i9dQZF1FwRDZZVSbIBqz"
BEDTIME_PLAYLIST="spotify:playlist:37i9dQZF1FwPghOHCcNTIB"

WINDDOWN_AT="00:15"
BEDTIME_AT="00:45"
FADEOUT_AT="01:00"

FADE_SECONDS=60

# launchd runs missed calendar jobs when the Mac wakes from sleep. A cue that
# fires more than this many minutes after its scheduled time is skipped.
GRACE_MINUTES=10

LABEL_PREFIX="local.sleepytime"
AGENT_DIR="$HOME/Library/LaunchAgents"
LOG="$HOME/Library/Logs/sleepytime.log"
SCRIPT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")"
DOMAIN="gui/$(id -u)"

CUES="winddown bedtime fadeout"

log() {
  echo "$(date '+%Y-%m-%d %H:%M:%S') $*"
}

die() {
  echo "sleepytime: $*" >&2
  exit 1
}

cue_time() {
  case "$1" in
  winddown) echo "$WINDDOWN_AT" ;;
  bedtime) echo "$BEDTIME_AT" ;;
  fadeout) echo "$FADEOUT_AT" ;;
  *) die "unknown cue: $1" ;;
  esac
}

minutes_of_day() {
  local hh="${1%%:*}" mm="${1##*:}"
  echo $((10#$hh * 60 + 10#$mm))
}

# True if now is between the scheduled time and GRACE_MINUTES after it.
on_time() {
  local scheduled now diff
  scheduled=$(minutes_of_day "$1")
  now=$(minutes_of_day "$(date +%H:%M)")
  diff=$(((now - scheduled + 1440) % 1440))
  [ "$diff" -le "$GRACE_MINUTES" ]
}

spotify_running() {
  pgrep -xq Spotify
}

spotify_state() {
  osascript -e 'tell application "Spotify" to player state as string' 2>/dev/null || true
}

# Launch Spotify hidden and in the background, so it doesn't take focus.
ensure_spotify() {
  spotify_running && return
  log "launching Spotify"
  open -gja Spotify
  local i
  for i in $(seq 1 30); do
    spotify_running && break
    sleep 1
  done
  spotify_running || die "Spotify did not start"
  # Spotify accepts AppleScript before it is ready to play; give it a moment.
  sleep 5
}

play_playlist() {
  local uri="$1" attempt
  ensure_spotify
  for attempt in 1 2 3 4 5; do
    osascript - "$uri" <<'EOF'
on run argv
    tell application "Spotify"
        play track (item 1 of argv)
        set repeating to true
    end tell
end run
EOF
    sleep 2
    if [ "$(spotify_state)" = "playing" ]; then
      log "playing $uri"
      return
    fi
    sleep 3
  done
  die "could not start $uri"
}

fade_out() {
  if ! spotify_running || [ "$(spotify_state)" != "playing" ]; then
    log "Spotify not playing; nothing to fade"
    return
  fi
  osascript - "$FADE_SECONDS" <<'EOF'
on run argv
    set fadeSeconds to (item 1 of argv) as integer
    set steps to 30
    tell application "Spotify"
        set startVolume to sound volume
        repeat with i from 1 to steps
            set sound volume to (startVolume * (steps - i) / steps) as integer
            delay (fadeSeconds / steps)
        end repeat
        pause
        delay 1
        -- Restore the volume so tomorrow's cue isn't silent. Spotify sometimes
        -- sets the volume one lower than requested, so correct for that.
        set sound volume to startVolume
        if sound volume < startVolume and startVolume < 100 then
            set sound volume to startVolume + 1
        end if
    end tell
end run
EOF
  log "faded out"
}

cmd_cue() {
  local cue="${1:-}" force="${2:-}"
  [ -n "$cue" ] || die "usage: sleepytime cue CUE [--force]"
  local scheduled
  scheduled=$(cue_time "$cue")
  if [ "$force" != "--force" ] && ! on_time "$scheduled"; then
    log "$cue: skipped, scheduled for $scheduled but it is $(date +%H:%M)"
    return
  fi
  log "$cue: running"
  case "$cue" in
  winddown) play_playlist "$WINDDOWN_PLAYLIST" ;;
  bedtime) play_playlist "$BEDTIME_PLAYLIST" ;;
  fadeout) fade_out ;;
  esac
}

plist_path() {
  echo "$AGENT_DIR/$LABEL_PREFIX.$1.plist"
}

write_plist() {
  local cue="$1" scheduled hh mm
  scheduled=$(cue_time "$cue")
  hh=$((10#${scheduled%%:*}))
  mm=$((10#${scheduled##*:}))
  cat >"$(plist_path "$cue")" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>$LABEL_PREFIX.$cue</string>
    <key>ProgramArguments</key>
    <array>
        <string>$SCRIPT</string>
        <string>cue</string>
        <string>$cue</string>
    </array>
    <key>StartCalendarInterval</key>
    <dict>
        <key>Hour</key>
        <integer>$hh</integer>
        <key>Minute</key>
        <integer>$mm</integer>
    </dict>
    <key>StandardOutPath</key>
    <string>$LOG</string>
    <key>StandardErrorPath</key>
    <string>$LOG</string>
</dict>
</plist>
EOF
}

unload() {
  launchctl bootout "$DOMAIN/$1" 2>/dev/null || true
}

cmd_on() {
  mkdir -p "$AGENT_DIR" "$(dirname "$LOG")"
  local cue
  for cue in $CUES; do
    unload "$LABEL_PREFIX.$cue"
    write_plist "$cue"
    launchctl bootstrap "$DOMAIN" "$(plist_path "$cue")"
    echo "loaded $LABEL_PREFIX.$cue at $(cue_time "$cue")"
  done
}

cmd_off() {
  local cue
  for cue in $CUES; do
    unload "$LABEL_PREFIX.$cue"
    rm -f "$(plist_path "$cue")"
    echo "removed $LABEL_PREFIX.$cue"
  done
}

cmd_status() {
  local cue
  for cue in $CUES; do
    if launchctl print "$DOMAIN/$LABEL_PREFIX.$cue" >/dev/null 2>&1; then
      echo "$cue: on ($(cue_time "$cue"))"
    else
      echo "$cue: off"
    fi
  done
  echo "log: $LOG"
}

# Runs a cue as a one-off launchd job rather than from this shell, so that
# macOS asks for permission to control Spotify now instead of at a scheduled cue.
cmd_test() {
  local cue="${1:-winddown}" label="$LABEL_PREFIX.test" plist
  cue_time "$cue" >/dev/null
  mkdir -p "$(dirname "$LOG")"
  plist="$(mktemp -d)/$label.plist"
  cat >"$plist" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>$label</string>
    <key>ProgramArguments</key>
    <array>
        <string>$SCRIPT</string>
        <string>cue</string>
        <string>$cue</string>
        <string>--force</string>
    </array>
    <key>RunAtLoad</key>
    <true/>
    <key>StandardOutPath</key>
    <string>$LOG</string>
    <key>StandardErrorPath</key>
    <string>$LOG</string>
</dict>
</plist>
EOF
  unload "$label"
  launchctl bootstrap "$DOMAIN" "$plist"
  echo "running $cue via launchd (allow the Spotify permission prompt if it appears)"
  sleep 2
  while launchctl print "$DOMAIN/$label" 2>/dev/null | grep -q 'state = running'; do
    sleep 1
  done
  unload "$label"
  rm -rf "$(dirname "$plist")"
  tail -n 5 "$LOG"
}

if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
  case "${1:-}" in
  on) cmd_on ;;
  off) cmd_off ;;
  status) cmd_status ;;
  test) cmd_test "${2:-}" ;;
  cue) cmd_cue "${2:-}" "${3:-}" ;;
  *)
    sed -n '2,15p' "$0" | sed 's/^# \{0,1\}//'
    exit 1
    ;;
  esac
fi
