Current section
Files
Jump to
Current section
Files
lib/integrations/jellyfin.ex
defmodule Integrations.Jellyfin do
@moduledoc """
Jellyfin media server monitor.
Checks server health and optionally queries for active sessions and library
statistics using the Jellyfin HTTP API.
Without an API key, only the public `/health` endpoint is checked — this
confirms the server process is alive but provides no session or library data.
With an API key, session counts and library sizes are collected.
Collection only — see `Integrations.Jellyfin.Display` (same package) for the
dashboard panel. `Display.BundledDefault` auto-hooks it whenever this
monitor starts, same as a single-module package would; a release without
`raven_web` simply never compiles the display half and runs this monitor
headless.
## Params
* `:base_url` — Jellyfin server URL (e.g. `http://jellyfin.local:8096`
or `https://media.example.com`). Required.
* `:api_key` — Jellyfin API key (optional). Generate one in
Dashboard → API Keys. Enables session and library checks.
* `:timeout_ms` — Request timeout in milliseconds. Defaults to `10000`.
* `:verify_tls` — Verify TLS certificates. Defaults to `true`.
## Health signal
* `:up` — `/health` returns healthy; API calls succeed (if key set).
* `:degraded` — Server reports unhealthy startup state in `/health`,
or an API call fails while the health check passes.
* `:down` — `/health` is unreachable or returns an error status.
"""
use CodeNameRaven.Monitor
@default_timeout_ms 10_000
@impl true
def params_template do
%{base_url: "http://", api_key: "", timeout_ms: "10000"}
end
@impl true
def params_schema do
[
base_url: [type: :string, required: true, doc: "Jellyfin server URL (e.g. http://jellyfin:8096)"],
api_key: [type: :string, required: false, doc: "API key for session and library data (Dashboard → API Keys)"],
timeout_ms: [type: :non_neg_integer, default: 10_000, doc: "Request timeout in milliseconds"],
verify_tls: [type: :boolean, default: true, doc: "Verify TLS certificates"]
]
end
@impl true
def target_uri(params) do
case get_param(params, :base_url) do
nil -> :none
url -> {:ok, url}
end
end
@impl true
def identity_params(params), do: %{base_url: get_param(params, :base_url)}
# ---------------------------------------------------------------------------
# Collect
# ---------------------------------------------------------------------------
@impl true
def collect(params, state) do
base_url = get_param(params, :base_url)
if is_nil(base_url) do
{:error, "missing required param :base_url", state}
else
timeout_ms = parse_int(get_param(params, :timeout_ms), @default_timeout_ms)
verify_tls = truthy?(get_param(params, :verify_tls), default: true)
api_key = get_param(params, :api_key)
ssl_opts = if verify_tls,
do: [verify: :verify_peer, cacerts: :public_key.cacerts_get()],
else: [verify: :verify_none]
req_base = [
connect_options: [transport_opts: ssl_opts],
receive_timeout: timeout_ms,
retry: false
]
with {:ok, health} <- fetch_health(base_url, req_base) do
{server_info, sessions, libraries, item_count} =
if api_key do
auth_headers = [{"X-MediaBrowser-Token", api_key}]
req_auth = Keyword.update(req_base, :headers, auth_headers, &(&1 ++ auth_headers))
info = fetch_server_info(base_url, req_auth)
sess = fetch_sessions(base_url, req_auth)
libs = fetch_libraries(base_url, req_auth)
item_count = fetch_item_count(base_url, req_auth)
{info, sess, libs, item_count}
else
{nil, nil, nil, nil}
end
result = %{
health: health,
server_info: server_info,
sessions: sessions,
libraries: libraries,
item_count: item_count,
api_enabled: not is_nil(api_key)
}
{:ok, result, state}
else
{:error, reason} -> {:error, reason, state}
end
end
end
# ---------------------------------------------------------------------------
# Healthy?
# ---------------------------------------------------------------------------
@impl true
def healthy?(result) do
cond do
result.health != :healthy -> :down
result.api_enabled and result.server_info == nil -> :degraded
true -> :up
end
end
# ---------------------------------------------------------------------------
# Metrics
# ---------------------------------------------------------------------------
@impl true
def metrics(result) do
base = %{healthy: if(result.health == :healthy, do: 1, else: 0)}
base
|> maybe_put(:active_sessions, result.sessions && length(result.sessions))
|> maybe_put(:library_count, result.libraries && length(result.libraries))
|> maybe_put(:library_item_count, result.item_count)
end
# ---------------------------------------------------------------------------
# API calls
# ---------------------------------------------------------------------------
defp fetch_health(base_url, req_opts) do
case Req.get("#{base_url}/health", req_opts) do
{:ok, %{status: 200, body: body}} ->
health = if is_binary(body) and String.contains?(body, "Healthy"),
do: :healthy,
else: :degraded
{:ok, health}
{:ok, %{status: _}} ->
{:ok, :unhealthy}
{:error, reason} ->
{:error, "unreachable: #{format_error(reason)}"}
end
end
defp fetch_server_info(base_url, req_opts) do
case Req.get("#{base_url}/System/Info", req_opts) do
{:ok, %{status: 200, body: body}} when is_map(body) -> body
_ -> nil
end
end
defp fetch_sessions(base_url, req_opts) do
case Req.get("#{base_url}/Sessions", req_opts) do
{:ok, %{status: 200, body: body}} when is_list(body) -> body
_ -> []
end
end
defp fetch_libraries(base_url, req_opts) do
case Req.get("#{base_url}/Library/VirtualFolders", req_opts) do
{:ok, %{status: 200, body: body}} when is_list(body) -> body
_ -> []
end
end
defp fetch_item_count(base_url, req_opts) do
case Req.get("#{base_url}/Items/Counts", req_opts) do
{:ok, %{status: 200, body: body}} when is_map(body) ->
~w[MovieCount SeriesCount EpisodeCount ArtistCount AlbumCount SongCount
MusicVideoCount BoxSetCount BookCount]
|> Enum.reduce(0, fn key, acc -> acc + (body[key] || 0) end)
_ -> nil
end
end
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
defp maybe_put(map, _key, nil), do: map
defp maybe_put(map, key, val), do: Map.put(map, key, val)
defp format_error(%{reason: reason}), do: inspect(reason)
defp format_error(reason), do: inspect(reason)
defp truthy?(nil, opts), do: Keyword.get(opts, :default, false)
defp truthy?("true", _), do: true
defp truthy?(true, _), do: true
defp truthy?("false", _), do: false
defp truthy?(false, _), do: false
defp truthy?(_, opts), do: Keyword.get(opts, :default, false)
defp get_param(params, key) when is_atom(key) do
v = params[key] || params[to_string(key)]
if is_binary(v) and String.trim(v) == "", do: nil, else: v
end
defp parse_int(nil, default), do: default
defp parse_int(v, _) when is_integer(v), do: v
defp parse_int(v, default) when is_binary(v) do
case Integer.parse(v) do
{n, _} -> n
:error -> default
end
end
defp parse_int(_, default), do: default
end