Joining Twitch Streams in Emacs

Vivienne likes using Emacs to manage its email. It prefers notmuch, especially since it lends itself incredibly well to totally flattening several disconnected email accounts and aliases into one location, but mu4e is another common option for this. Email already is how Vivienne receives channel live notifications for twitch.tv. Recently, it learned that you can watch Twitch streams using mpv, whether via yt-dlp or streamlink (streamlink seems to be more reliable for this purpose), and that Twitch chats are normal IRC channels. This caused it a flash of inspiration: What if it could open Twitch streams in MPV and, and their associated chat channels in ERC, directly from those notification emails in Emacs?

Well, first it implemented a command which allows it to start an MPV process connected to a stream via Streamlink from Emacs. When called interactively, the following will prompt for a username to connect to the stream. Or, the username can be provided as an argument when called from Elisp. This is a good start.

(defun twitch-open-stream (username)
  "Open a twitch stream in MPV."
  (interactive "Musername: ")
  (start-process username nil
                 "streamlink" "--player" "mpv"
                 (format "twitch.tv/%s" username)
                 "480p,480p60,720p,720p60"))

Next, it needed a command which would join the chat channel. There are a couple of notable things about this. Firstly, to authenticate, you provide your Twitch username as your nick, and an oauth token with the user:read:chat and user:write:chat scopes as your password, according to this page of the Twitch developer docs. According to the docs, you may fetch a token using the Twitch CLI like so:

twitch token --user-token --scopes "user:read:chat user:write:chat"

Great! Now we ought to store this authentication information somewhere. Emacs ships with a couple of APIs for managing secrets, perhaps most commonly used is auth-source, but Vivienne prefers to use the secrets API so that it can store this information on its system keyring which is unlocked by the session manager when it logs in.

To add an entry to your keyring, go ahead and run M-x ielm so that you may make a call to secrets-create-item:

(secrets-create-item "Default keyring"
                     "twitch"
                     "oauth:your-oauth-token-here"
                     :user "your-twitch-username-here")

With this in mind, Vivienne wrote a function which would join the Twitch IRC server, simply calling erc-tls:

(defun twitch-join-network ()
  (erc-tls :server "irc.chat.twitch.tv"
           :port 6697
           :id "Twitch"
           :password
           (secrets-get-secret "Default keyring" "twitch")
           :nick
           (secrets-get-attribute "Default keyring" "twitch" :user)))

This is where the next little quirk comes up. By default, the Twitch IRC server does not expose any commands, nor notifies the client of channel membership. Instead, upon connecting to the server, you are expected to request those capabilities by sending the following command:

CAP REQ :twitch.tv/membership twitch.tv/commands

Which, may be sent from elisp as

(erc-cmd-QUOTE CAP REQ :twitch.tv/membership twitch.tv/commands)

With this, Vivienne wrote a function which implements the following logic: If the Twitch server is already connected, then use erc-cmd-JOIN to join the requested channel. But if its not already connected, then start a new connection. But the commands to request capabilities and join the channel cannot be sent until the connection has been established! So, add a hook which requests capabilities and join the requested channel and removes itself to erc-after-connect:

(defun twitch-join-chat (username)
  (interactive "Musername: ")
  (let ((b (get-buffer "Twitch")))
    (if (erc-server-process-alive b)
      (with-current-buffer b
        (erc-cmd-JOIN (format "#%s" username)))
      (with-current-buffer (twitch-join-network)
      (letrec ((f (lambda (&rest _)
                  (remove-hook 'erc-after-connect f t)
                  (erc-cmd-QUOTE "CAP REQ :twitch.tv/membership twitch.tv/commands")
                  (erc-cmd-JOIN (format "#%s" username)))))
        (add-hook 'erc-after-connect f nil t))))))

Note, the last argument given to add-hook is t, meaning to add that hook buffer-locally.

Finally, Vivienne implemented a url handler to add to browse-url-handlers, so that when clicking Twitch links it'll call twitch-open-stream and twitch-join-chat:

(defun open-twitch-url (url &rest _)
  (string-match "https:\\/\\/www\\.twitch\\.tv\\/\\(.+\\)\\/?" url)
  (twitch-open-stream (match-string 1 url))
  (twitch-join-chat (match-string 1 url)))

(setq browse-url-handlers
      `(("https://www.twitch.tv/" . open-twitch-url)))

Notmuch

At the beginning of the article, Vivienne wrote that it uses notmuch to manage it email. This opens a great opportunity. Vivienne wants to receive all Twitch notifications, so that it doesn't miss a stream. However, most of the time, these notifications won't be acted on, because Vivienne doesn't actually want to watch every stream. It runs notmuch new on a cron job which runs every fifteen minutes. Taking advantage of this, in the Notmuch post-new script, it added a line to remove the inbox and unread tags from notifications from Twitch that are more than three hours old:

notmuch tag -inbox -unread -- from:*@twitch.tv and not date:3_hrs..now

2024-10-03