#!/usr/bin/env bash
# music - shuffle-play a folder of audio files with mpv, controllable from
# anywhere (hotkeys, panel, media keys).
#
#   music                   shuffle the whole library in this terminal
#   music TEXT              shuffle only files whose path contains TEXT
#   music bg [TEXT]         start playback in the background
#   music start             hotkey helper: pause/resume if playing, else `bg`
#   music next | prev       skip track
#   music toggle            pause / resume
#   music stop              stop (remembers the track and position)
#   music now               print the current track (exit 1 if nothing plays)
#   music bass [dB]         show or set a bass shelf for this player only
#   music rescan            rebuild the cached track list
#   music set-dir PATH      save PATH as the library folder
#   music --dir PATH ...    use PATH for this run only
#
# In the terminal player: > next, < prev, space pause, 9/0 volume, q quit.
#
# Settings: ~/.config/cosmic-tools/music.conf (shell KEY=VALUE)
#   MUSIC_ROOT="$HOME/Music"   library folder (searched recursively)
#   MUSIC_MPV_OPTS=""          extra mpv options, e.g. "--volume=70"
#   MUSIC_NOTIFY=1             desktop notifications on track changes
# Environment: MUSIC_ROOT overrides the file; MUSIC_DRYRUN=1 lists matches only.
set -uo pipefail

CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}"
CONF="${MUSIC_CONF:-$CONFIG_HOME/cosmic-tools/music.conf}"
# Environment variables win over the settings file.
ENV_ROOT="${MUSIC_ROOT:-}" ENV_OPTS="${MUSIC_MPV_OPTS:-}" ENV_NOTIFY="${MUSIC_NOTIFY:-}"
MUSIC_ROOT="" MUSIC_MPV_OPTS="" MUSIC_NOTIFY=1
# shellcheck disable=SC1090
[ -r "$CONF" ] && . "$CONF"
[ -n "$ENV_ROOT" ] && MUSIC_ROOT="$ENV_ROOT"
[ -n "$ENV_OPTS" ] && MUSIC_MPV_OPTS="$ENV_OPTS"
[ -n "$ENV_NOTIFY" ] && MUSIC_NOTIFY="$ENV_NOTIFY"

if [ "${1:-}" = "--dir" ]; then
    [ -n "${2:-}" ] || { echo "usage: music --dir PATH [command]" >&2; exit 2; }
    MUSIC_ROOT="$2"; shift 2
fi
ROOT="${MUSIC_ROOT:-$HOME/Music}"
ROOT="${ROOT/#\~/$HOME}"

CACHE_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/cosmic-tools/music"
CACHE="$CACHE_DIR/tracks-$(printf '%s' "$ROOT" | md5sum | cut -c1-12)"
SOCK="${MUSIC_SOCKET:-${XDG_RUNTIME_DIR:-/tmp}/mpv-music.sock}"
LAST="${XDG_STATE_HOME:-$HOME/.local/state}/cosmic-tools/music-last"
BASS="$CONFIG_HOME/cosmic-tools/music-bass"
HERE="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
export MUSIC_SOCKET="$SOCK"

# One JSON IPC request to mpv. Prints the reply's data (strings raw, other
# values as JSON); exits non-zero if the player is not there or refused.
IPC_PY='
import json, socket, sys
sock, command = sys.argv[1], json.loads(sys.argv[2])
try:
    s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
    s.settimeout(2)
    s.connect(sock)
    s.sendall((json.dumps({"command": command}) + "\n").encode())
    buf = b""
    while True:
        chunk = s.recv(65536)
        if not chunk:
            sys.exit(1)
        buf += chunk
        lines = buf.split(b"\n")
        buf = lines.pop()
        for line in lines:
            try:
                reply = json.loads(line)
            except ValueError:
                continue
            if "error" not in reply:
                continue          # an event, not our reply
            if reply["error"] != "success":
                sys.exit(1)
            data = reply.get("data")
            if data is not None:
                print(data if isinstance(data, str) else json.dumps(data))
            sys.exit(0)
except OSError:
    sys.exit(1)
'
ipc() {
    [ -S "$SOCK" ] || return 1
    python3 -c "$IPC_PY" "$SOCK" "$1"
}

osd() {
    [ "$MUSIC_NOTIFY" = 1 ] || return 0
    command -v notify-send >/dev/null && notify-send -a music -t 2500 -i multimedia-player "$1" "${2:-}" 2>/dev/null
    return 0
}

scan() {  # write the full track list for $ROOT to the cache, atomically
    mkdir -p "$CACHE_DIR"
    find "$ROOT" -type f \
        \( -iname '*.mp3' -o -iname '*.m4a' -o -iname '*.opus' -o -iname '*.ogg' \
           -o -iname '*.flac' -o -iname '*.wav' -o -iname '*.webm' -o -iname '*.aac' \) -print0 \
        > "$CACHE.tmp" 2>/dev/null && [ -s "$CACHE.tmp" ] && mv "$CACHE.tmp" "$CACHE"
    rm -f "$CACHE.tmp"
}

collect() {  # populate TRACKS, optionally filtered by $1
    # Walking a large library (especially on a network share) can take several
    # seconds, too slow for a hotkey. So play from the cached list at once and
    # refresh it in the background; only the very first run waits for the scan.
    TRACKS=()
    [ -d "$ROOT" ] || return 0
    if [ -s "$CACHE" ]; then
        ( renice -n 10 "$BASHPID" >/dev/null 2>&1; scan ) >/dev/null 2>&1 &
    else
        scan
    fi
    mapfile -d '' TRACKS < <(
        if [ -n "${1:-}" ]; then grep -zi -- "$1" "$CACHE" 2>/dev/null; else cat "$CACHE" 2>/dev/null; fi
    )
}

playing() {  # print the current title; succeed only if something really plays
    [ -S "$SOCK" ] || return 1
    local t
    t=$(ipc '["get_property","media-title"]') || return 1
    [ -n "$t" ] || return 1
    printf '%s\n' "$t"
}

start_mpris() {
    # Put the player on D-Bus (MPRIS) so media keys and desktop controls reach
    # it. The bridge waits for mpv's socket and exits when mpv goes away.
    local bridge="$HERE/music-mpris"
    [ -x "$bridge" ] || return 0
    setsid bash -c 'for _ in $(seq 40); do [ -S "$1" ] && exec "$2"; sleep 0.25; done' \
        _ "$SOCK" "$bridge" >/dev/null 2>&1 &
}

save_position() {
    local path pos
    path=$(ipc '["get_property","path"]') || return 0
    pos=$(ipc '["get_property","time-pos"]') || pos=0
    [ -n "$path" ] || return 0
    mkdir -p "$(dirname "$LAST")"
    printf '%s\n%s\n' "$path" "${pos:-0}" > "$LAST"
}

resume_at() {
    # mpv's --start would apply to every file in the playlist, so the seek is
    # sent over IPC once playback has begun.
    setsid bash -c 'for _ in $(seq 40); do
        if [ -S "$1" ]; then sleep 0.5; python3 -c "$3" "$1" "[\"seek\", $2, \"absolute\"]" >/dev/null 2>&1; exit; fi
        sleep 0.25
    done' _ "$SOCK" "$1" "$IPC_PY" >/dev/null 2>&1 &
}

bass_filter() {
    # A low shelf inside mpv, so only this player is affected. The limiter
    # matters: a digital bass boost has no headroom and would clip without it.
    local g
    g=$(cat "$BASS" 2>/dev/null) || g=0
    [ -n "$g" ] || g=0
    awk -v g="$g" 'BEGIN { exit !(g > 0.01 || g < -0.01) }' || return 1
    printf 'lavfi=[bass=g=%s:f=110:w=0.6,alimiter=limit=0.97]' "$g"
}

set_conf() {  # set_conf KEY VALUE  (atomic rewrite of the settings file)
    mkdir -p "$(dirname "$CONF")"
    local tmp="$CONF.tmp.$$"
    { [ -r "$CONF" ] && grep -v "^[[:space:]]*\(export[[:space:]]\+\)\?$1=" "$CONF"
      printf '%s=%s\n' "$1" "$2"; } > "$tmp"
    mv "$tmp" "$CONF"
}

need_mpv() { command -v mpv >/dev/null || { echo "mpv is not installed (sudo apt install mpv)" >&2; osd "music: mpv is not installed"; exit 1; }; }

start_bg() {
    need_mpv
    if ipc '["get_property","pid"]' >/dev/null 2>&1; then
        osd "Music already playing" "$(playing)"; return 0
    fi
    rm -f "$SOCK"
    collect "$*"
    if [ ! -d "$ROOT" ]; then osd "Music folder not found" "$ROOT"; echo "music folder not found: $ROOT" >&2; return 1; fi
    [ "${#TRACKS[@]}" -eq 0 ] && { osd "No tracks found" "${*:-$ROOT}"; echo "no tracks found" >&2; return 1; }
    # Shuffled here rather than with mpv --shuffle, so the track that was
    # playing when music was last stopped can be put FIRST.
    mapfile -d '' TRACKS < <(printf '%s\0' "${TRACKS[@]}" | shuf -z)
    local resume_pos=0 last_path
    if [ -s "$LAST" ]; then
        last_path=$(sed -n 1p "$LAST"); resume_pos=$(sed -n 2p "$LAST")
        if [ -f "$last_path" ] && [[ "$last_path" == "$ROOT"/* ]]; then
            mapfile -d '' TRACKS < <(
                printf '%s\0' "$last_path"
                printf '%s\0' "${TRACKS[@]}" | grep -zvxF -- "$last_path")
        else
            resume_pos=0
        fi
    fi
    osd "♪ Shuffling ${#TRACKS[@]} tracks" "${*:-$(basename "$ROOT")}"
    start_mpris
    [ "${resume_pos%%.*}" -gt 0 ] 2>/dev/null && resume_at "$resume_pos"
    local af extra=()
    af=$(bass_filter) && extra=(--af="$af")
    # shellcheck disable=SC2086
    setsid mpv --no-video --no-terminal --loop-playlist $MUSIC_MPV_OPTS "${extra[@]}" \
               --input-ipc-server="$SOCK" -- "${TRACKS[@]}" >/dev/null 2>&1 &
}

case "${1:-}" in
  -h|--help|help)
      sed -n '2,26p' "$0" | sed 's/^# \{0,1\}//' ;;
  next|prev|previous)
      if [ "$1" = next ]; then ipc '["playlist-next","force"]' >/dev/null
      else ipc '["playlist-prev","force"]' >/dev/null; fi || { echo "No music playing." >&2; exit 1; }
      sleep 0.4; t=$(playing); osd "♪ ${t:-Next track}"; printf '%s\n' "$t" ;;
  toggle|pause)
      ipc '["cycle","pause"]' >/dev/null || { echo "No music playing." >&2; exit 1; }
      if [ "$(ipc '["get_property","pause"]')" = true ]; then osd "Paused"; else osd "♪ $(playing)"; fi ;;
  stop|quit)
      if playing >/dev/null; then
          save_position
          ipc '["quit"]' >/dev/null; osd "Music stopped"
      fi
      rm -f "$SOCK" ;;
  now)
      playing ;;
  start)
      if playing >/dev/null; then exec "$0" toggle; fi
      shift; start_bg "$@" ;;
  bg)
      shift; start_bg "$@" ;;
  bass)
      if [ -n "${2:-}" ]; then
          awk -v g="$2" 'BEGIN { exit !(g+0 == g && g >= -12 && g <= 12) }' \
              || { echo "bass must be a number of dB between -12 and 12" >&2; exit 2; }
          mkdir -p "$(dirname "$BASS")"; printf '%s\n' "$2" > "$BASS"
          if playing >/dev/null; then    # apply live
              if f=$(bass_filter); then ipc "[\"af\",\"set\",\"$f\"]" >/dev/null
              else ipc '["af","clr",""]' >/dev/null; fi
          fi
          osd "Music bass ${2} dB"
      fi
      echo "music bass: $(cat "$BASS" 2>/dev/null || echo 0) dB" ;;
  rescan)
      [ -d "$ROOT" ] || { echo "music folder not found: $ROOT" >&2; exit 1; }
      scan; echo "$(tr -cd '\0' < "$CACHE" 2>/dev/null | wc -c) tracks in $ROOT" ;;
  set-dir)
      [ -n "${2:-}" ] || { echo "usage: music set-dir PATH" >&2; exit 2; }
      dir=$(realpath -m -- "${2/#\~/$HOME}")
      [ -d "$dir" ] || echo "note: $dir does not exist yet" >&2
      case "$dir" in
          "$HOME"/*) stored="\"\$HOME/${dir#"$HOME"/}\"" ;;
          *) stored=$(printf '%q' "$dir") ;;
      esac
      set_conf MUSIC_ROOT "$stored"
      echo "music folder set to $dir (in $CONF)" ;;
  *)
      need_mpv
      collect "$*"
      [ "${#TRACKS[@]}" -eq 0 ] && { echo "No tracks found in $ROOT${*:+ matching \"$*\"}" >&2; exit 1; }
      if [ -n "${MUSIC_DRYRUN:-}" ]; then printf '%s\n' "${TRACKS[@]}"; echo "${#TRACKS[@]} tracks"; exit 0; fi
      echo "Shuffling ${#TRACKS[@]} tracks${*:+ matching \"$*\"}..."
      rm -f "$SOCK"
      start_mpris
      extra=()
      af=$(bass_filter) && extra=(--af="$af")
      # shellcheck disable=SC2086
      exec mpv --no-video --shuffle --loop-playlist --term-osd-bar $MUSIC_MPV_OPTS "${extra[@]}" \
               --input-ipc-server="$SOCK" \
               --msg-level=all=warn --term-playing-msg='♪ ${media-title}' -- "${TRACKS[@]}" ;;
esac
