A while ago, I wrote a CLI script to display what my Plex server is playing using jq and the Tautulli API. Check it out if you use Plex/Tautulli — or if you want to see some moderately advanced jq in action.

tautulli-status uses a Tautulli instance’s API to check what your Plex server is currently playing and pretty prints it:

% tautulli-status
------------------------------------------------------------------------------

Name:      Arrested Development - S02E13 - Motherboy XXX
Duration:  00:21:55
Progress:  73% (00:16:00 watched - 00:05:55 remaining)
Player:    Plex for Apple TV (Office Apple TV @ 10.0.1.102)
State:     Paused
Air Date:  Mar 13, 2005
Rating:    TV-14
Summary:   Lucille abducts George Michael for a mother-son dance, Buster
           adjusts to having a hook for a hand and Gob reunites with his
           estranged wife. Also, Tobias stars as George Sr. in a biopic about
           the Bluth family, which causes Lindsay to become more attracted to
           him.

------------------------------------------------------------------------------

Name:      Bob's Burgers - S01E06 - Sheesh! Cab, Bob?
Duration:  00:21:41
Progress:  93% (00:20:10 watched - 00:01:31 remaining)
Player:    Plex for Apple TV (Bedroom Apple TV @ 10.0.1.103)
State:     Playing
Air Date:  Mar 6, 2011
Rating:    TV-14
Summary:   Tina is desperate to get her first kiss at her 13th birthday party.
           But after Louise breaks the deep fryer, Bob takes a second job as a
           late-night cab driver to pay for Tina’s party. Things keep getting
           worse for Bob when the parents of Tina’s crush refuse to let their
           son attend the party, and Bob has to do everything in his power to
           save his daughter’s big day.

The script is below. If you want to use it yourself, toss in on your $PATH (maybe at /usr/local/bin), then head over to your Tautulli settings and grab an API key. To setup the config file:

jq --null-input \
  --arg api_key "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" \
  --arg api_url "https://tautulli.local/api/v2\" \
  '$ARGS.named' > "${XDG_CONFIG_HOME:-$HOME/.config/tautulli-stats.json"

chmod 600 "${XDG_CONFIG_HOME:-$HOME/.config/tautulli-stats.json"

Full script (also on GitHub):

#!/usr/bin/env bash
# Usage: tautulli-status [--text|--json|--raw]
# Summary: See what's playing on Plex using the Tautulli API
#
# NAME
#   tautulli-status -- see what's playing on Plex using the Tautulli API
#
# SYNOPSIS
#   tautulli-status <options>
#
# DESCRIPTION
#   See what's playing on Plex using the Tautulli API. Requires a running
#   Tautulli instance.
#
# OPTIONS
#   -p, --plain
#       Don't colorize output.
#
#   -t, --text
#       Show results in plain text. This is the default format.
#
#   -j, --json
#       Show results in JSON format. Note that the format differs from the
#       standard Tautulli API payload -- use `--raw` to obtain the raw JSON
#       payload from Tautulli.
#
#   -r, --raw
#       Show raw JSON payload from the Tautulli API.
#
# CONFIGURATION
#   Configuration is stored in `$HOME/.config/tautulli-stats.json` with:
#
#   {
#     "api_key": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
#     "api_url": "https://tautulli.local/api/v2"
#   }

# Example Output:
#
# ------------------------------------------------------------------------------
#
# Name:      Arrested Development - S02E13 - Motherboy XXX
# Duration:  00:21:55
# Progress:  73% (00:16:00 watched - 00:05:55 remaining)
# Player:    Plex for Apple TV (Office Apple TV @ 10.0.1.102)
# State:     Paused
# Air Date:  Mar 13, 2005
# Rating:    TV-14
# Summary:   Lucille abducts George Michael for a mother-son dance, Buster
#            adjusts to having a hook for a hand and Gob reunites with his
#            estranged wife. Also, Tobias stars as George Sr. in a biopic about
#            the Bluth family, which causes Lindsay to become more attracted to
#            him.
#
# ------------------------------------------------------------------------------
#
# Name:      Bob's Burgers - S01E06 - Sheesh! Cab, Bob?
# Duration:  00:21:41
# Progress:  93% (00:20:10 watched - 00:01:31 remaining)
# Player:    Plex for Apple TV (Bedroom Apple TV @ 10.0.1.103)
# State:     Playing
# Air Date:  Mar 6, 2011
# Rating:    TV-14
# Summary:   Tina is desperate to get her first kiss at her 13th birthday party.
#            But after Louise breaks the deep fryer, Bob takes a second job as a
#            late-night cab driver to pay for Tina’s party. Things keep getting
#            worse for Bob when the parents of Tina’s crush refuse to let their
#            son attend the party, and Bob has to do everything in his power to
#            save his daughter’s big day.

if [[ "$DEBUG" ]]; then
  export PS4='+ [${BASH_SOURCE##*/}:${LINENO}] '
  set -x
fi

set -euo pipefail

[[ -t 1 ]] && ANSI=1

# Formats activity from Tautulli API.
#
# $1 - format, "raw", "text", or "json"
format() {
  local colorize="--monochrome-output"

  [[ "${ANSI:-}" = "1" ]] && colorize="--color-output"

  if [[ "$1" = "raw" ]]; then
    cat
    echo
  else
    jq "$colorize" \
      --arg ansi "${ANSI:-}" \
      --arg format "$1" \
      --raw-output '
def capitalize(str):
  str | sub("^(?<char>.)"; "\(.char | ascii_upcase)")
  ;

def ansi(code; str):
  if $ansi == "1" then
    "\u001B[\(code)m\(str)\u001B[0m"
  else
    str
  end
  ;

def bold(str):
  ansi(1; str)
  ;

def blue(str):
  ansi(34; str)
  ;

def bar:
  bold("------------------------------------------------------------------------------")
  ;

def trim:
  . |
    gsub("^(\\n+|\\s+)"; "") |
    gsub("(\\n|\\s+)$"; "")
  ;

def normalize_whitespace:
  . |
    gsub("\\s+"; " ") |
    trim
  ;

def wrap:
  (11) as $indent |
  (78 - $indent) as $length |

  . | gsub("(?<line>.{1,\($length)})(\\s+|$)"; "\(.line)\n\(" " * $indent // "")")
  ;

def round(decimal):
  (decimal | tostring | split(".")) as $fields |
  ($fields[0] | tonumber) as $integer |
  ("0.\($fields[1] // 0)" | tonumber) as $decimal |

  if $decimal >= 0.5 then
    $integer + 1
  else
    $integer
  end
  ;

def zero_pad(i):
  if i != "" and (i | tonumber) < 10 then
    "0\(i)"
  else
    "\(i)"
  end
  ;

def seconds_to_time(seconds):
  (seconds | tonumber / 3600 | floor) as $hours |
  (seconds | tonumber % 3600 / 60 | floor) as $minutes |
  (seconds | tonumber % 60) as $seconds |

  "\(zero_pad($hours)):\(zero_pad($minutes)):\(zero_pad($seconds))"
  ;

def format_state(state):
  capitalize(state)
  ;

def map_now_playing:
  . | sort_by(.ip_address) | map(
    (
      (.media_type == "episode") as $is_episode |
      (.parent_media_index | tostring) as $season |
      (.media_index | tostring) as $episode |

      if ($is_episode and ($season != "") and ($episode != "")) then
        "S\(zero_pad($season))E\(zero_pad($episode))"
      else
        null
      end
    ) as $episode_code |

    (
      if .media_type == "episode" then
        "\(.grandparent_title) - \($episode_code) - \(.title)"
      else
        .title
      end
    ) as $name |

    (
      round(.duration | tonumber / 1000)
    ) as $duration_seconds |

    (
      seconds_to_time($duration_seconds)
    ) as $duration |

    (
      round($duration_seconds * (.progress_percent | tonumber / 100))
    ) as $progress_seconds |

    (
      seconds_to_time($progress_seconds)
    ) as $progress |

    (
      round($duration_seconds - $progress_seconds)
    ) as $remaining_seconds |

    (
      seconds_to_time($remaining_seconds)
    ) as $remaining |

    (
      if .media_type == "episode" then
        .grandparent_thumb
      else
        .thumb
      end
    ) as $thumb |

    {
      name: $name,
      date: .originally_available_at,
      ip_address,
      player,
      device,
      product,
      media_type,
      grandparent_title,
      parent_title,
      title,
      full_title,
      content_rating,
      thumb: $thumb,
      state: format_state(.state),
      summary: .summary | normalize_whitespace,
      progress_percent: .progress_percent | tonumber,
      progress_seconds: $progress_seconds,
      progress: $progress,
      duration: $duration,
      duration_seconds: $duration_seconds,
      remaining: $remaining,
      remaining_seconds: $remaining_seconds,
      episode_code: $episode_code
    }
  ) |
  if $format == "text" then
    map(
      [
        "\(bar)",
        "",
        "\(blue(bold("Name:")))      \(.name | wrap | trim)",
        "\(blue(bold("Duration:")))  \(.duration)",
        "\(blue(bold("Progress:")))  \(.progress_percent)% (\(.progress) watched - \(.remaining) remaining)",
        "\(blue(bold("Player:")))    \(.product) (\(.player) @ \(.ip_address))",
        "\(blue(bold("State:")))     \(.state)",
        "\(blue(bold("Air Date:")))  \("\(.date)T00:00:00Z" | fromdate | strftime("%b %-d, %Y"))",
        "\(blue(bold("Rating:")))    \(.content_rating)",
        "\(blue(bold("Summary:")))   \(.summary | wrap | trim)"
      ] | join("\n")
    ) | join("\n\n")
  else
    .
  end
  ;

def process(sessions):
  # Start processing
  if (sessions | length) == 0 then
    if $format == "text" then
      "Nothing playing"
    else
      []
    end
  else
    sessions | map_now_playing
  end
  ;

process(.response.data.sessions)
'
  fi
}

# Prints help text from the top of this file
#
# $1 - h to show one line usage, otherwise print full help
print_help() {
  sed -ne '/^#/!q;s/^#$/# /;/^# /s/^# //p' < "$0" |
    awk -v f="$1" 'f == "h" && $1 == "Usage:" { print; exit }; f != "h"'

  return 1
}

# Get activity from Tautulli API
get_activity() {
  local file="${XDG_CONFIG_HOME:-$HOME/.config}/tautulli-stats.json" \
    raw api_key api_url

  if [[ -r "$file" ]]; then
    raw="$(< "$file")"
    api_key="$(jq -r .api_key <<< "$raw")"
    api_url="$(jq -r .api_url <<< "$raw")"
  else
    {
      echo "${0##*/}: Error, configuration not found!"
      echo
      echo "Create \`$file' with:"
      echo
      echo "  jq --null-input \\"
      echo "    --arg api_key \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\" \\"
      echo "    --arg api_url \"https://tautulli.local/api/v2\" \\"
      echo "    '\$ARGS.named' > $file"
    } >&2
    return 1
  fi

  curl --silent "${api_url}?apikey=${api_key}&cmd=get_activity"
}

main() {
  local fmt

  while [[ "$#" -gt 0 ]]; do
    case "$1" in
      -p | --plain) ANSI=0; shift;;
      -r | --raw) fmt="raw"; shift;;
      -j | --json) fmt="json"; shift;;
      -t | --text) fmt="text"; shift;;
      -h | --help) print_help "${1//-/}"; shift;;
      *) echo "${0##*/}: Invalid option '$1'" >&2; return 1;;
    esac
  done

  if ! type jq &>/dev/null; then
    echo "${0##*/}: Error, jq not found!" >&2
    return 1
  fi

  get_activity | format "${fmt:-text}"
}

main "$@"