Skip to content

feat(discord): publish the playing title, episode and position to Discord - #655

Closed
Zaphkiel-Ivanovna wants to merge 1 commit into
andrewrabert:mainfrom
Zaphkiel-Ivanovna:feat/discord-rich-presence
Closed

feat(discord): publish the playing title, episode and position to Discord#655
Zaphkiel-Ivanovna wants to merge 1 commit into
andrewrabert:mainfrom
Zaphkiel-Ivanovna:feat/discord-rich-presence

Conversation

@Zaphkiel-Ivanovna

Copy link
Copy Markdown

Adds Discord Rich Presence: series and episode, or film and year, plus the playback position. New jfn-discord crate, no new external dependency, off by default.

image

Action required from the maintainer

Inert until a Discord application exists. DEFAULT_APPLICATION_ID is left empty so nothing ships pointing at a personal account.

  1. Create an app at https://discord.com/developers/applications. Its name is what Discord renders as "Watching name", so name it Jellium Desktop.
  2. Upload resources/discord/logo.png and pause.png under Rich Presence → Art Assets. Asset names must be logo and pause; Discord takes them from the filenames, so upload as-is.
  3. Set the Application ID in DEFAULT_APPLICATION_ID (src/discord/src/sink.rs:14).

The ID is public in every presence payload, not a secret. Merging before step 3 is harmless.

Implementation

  • Registers through jfn_playback::register_event_sink, not MediaSink: Discord is cross-platform, that trait gives one sink per platform and is already taken.
  • Mirrors jfn-mpris: pure projection module for the rules, dedicated thread for the socket.
  • IPC in tree (length-prefixed JSON over Unix socket or named pipe) rather than a dependency in a workspace with no async runtime.
  • Reads on their own thread: Discord echoes every command and expects PONG, so an undrained socket blocks writes, and stop() must not wait on I/O. Backoff is loop state, not a sleep.
  • Absolute timestamps, so nothing is pushed on a timer. The bar is omitted unless playback is steadily rolling, the only way to stop the client extrapolating.
  • MediaMetadata gains seven fields parse_metadata_json already received and dropped. Input::Metadata boxed for clippy::large_enum_variant.
  • Flatpak gains --filesystem=xdg-run/discord-ipc-0; the socket is invisible from the sandbox otherwise.

Settings

New Discord section in Client Settings, mirrored in settings.json. Read at startup, so toggling needs a restart.

Key Type Default
discordRichPresence bool false
discordApplicationId string empty, falls back to the built-in ID

Your call

The large image is the Jellyfin poster URL. Discord's proxy embeds the origin host, so the server hostname becomes visible in the payload, and it only resolves for a publicly reachable server. One branch in projection.rs makes it logo-only.

Testing

  • 51 unit tests in jfn-discord, 272 workspace-wide, just lint clean.
  • macOS against a live client: play, pause, seek, speed change, track change, Discord restarted mid-playback.
  • Linux and Windows compile and pass tests but are untested at runtime; the Windows named pipe has never run against a real client.

…cord

Show the current series and episode, or film and year, on the user's
Discord profile along with the playback position.

The new jfn-discord crate splits the way jfn-mpris does: a pure
projection module holding the display rules, unit tested without a socket
or a clock, and a dedicated thread owning the connection. It registers
with jfn_playback::register_event_sink rather than the per-platform
MediaSink trait, which returns one sink per platform and is already taken
by MPRIS, NowPlaying and SMTC.

The RPC protocol is a length-prefixed JSON frame over a Unix socket or a
named pipe, small enough to implement here instead of taking a
dependency. Reads run on their own thread: Discord echoes every command
and sends PING expecting PONG, so an undrained socket eventually blocks
writes, and keeping reads off the sink thread means stop() never waits on
I/O. Reconnect backoff is loop state rather than a sleep for the same
reason.

Timestamps are absolute, so Discord advances the progress bar itself and
nothing needs pushing on a timer; an update goes out only when the
relation between position and wall clock changes. The bar is omitted
unless playback is steadily rolling, which is the only way to stop the
client extrapolating through pauses, seeks and buffer underruns.

MediaMetadata gains the season number, production year, item kind and
primary image URL, all four of which the CEF layer already received and
dropped, plus the provider ids behind the IMDb, AniList and TMDb links.
Those ids come from the server and end up inside a URL, so they are
validated first. Input::Metadata is boxed to keep the enum balanced now
the struct is larger.

The Flatpak manifest gains access to the host Discord socket, invisible
from inside the sandbox, without which the feature would be inert on that
target alone.

Off by default behind discordRichPresence, and inert until an application
id is set, either in the client settings page or as discordApplicationId
in settings.json, falling back to a compiled-in constant. A non-numeric
id is refused with a log line rather than left to fail silently at
connect time, since the field is now free text in the UI.
@andrewrabert

andrewrabert commented Aug 9, 2026

Copy link
Copy Markdown
Owner

This isn't something I want to add into the codebase proper due to reliance upon a third party API.

Please open an issue for Discord support and I'll mark it as blocked by #371

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants