Categories: emacsconf

View topic page - RSS - Atom - Subscribe via email

EmacsConf backstage: Figuring out our maximum number of simultaneous BigBlueButton users

| emacsconf

[2023-12-30 Sat] Update: fix total number of unique users; I flipped the assoc so that the car is the user ID and the cdr is the name

A few people have generously donated money to EmacsConf, so now we're thinking of how to use that money effectively to scale EmacsConf up or help people be happier.

One of the things I'd like to improve is our BigBlueButton web conferencing setup, since fiddling with the screen layout was a little annoying this year. We're using BigBlueButton 2.2, which was released in 2020. The current version is 2.7 and has a few improvements that I think would be very useful.

  • Better layouts mean that webcams can be on the left side, leaving more space for the presentation, which means a more pleasant viewing experience and less manual fiddling with the sizes of things.
  • Built-in timers could help speakers and hosts easily stay on track.
  • A unified WEBM export (instead of separate videos for webcams and screensharing) means less post-processing with ffmpeg, and probably a better layout too.
  • The option to share system audio when using a Chromium-based browser means easier multimedia presentations, since setting up audio loopbacks can be tricky.

We'd love to use those improvements at the next EmacsConf, and they might be handy for the handful of other Emacs meetups who use our BigBlueButton setup from time to time. I think reducing the mental load from managing screen layouts might be an important step towards making it possible to have a third track.

The current BigBlueButton is a 6-core 3.4GHz virtual machine with 8 GB RAM. During EmacsConf 2023, the CPU load stayed at around 35%, with 4 GB memory used. It idles at 3% CPU and about 3 GB RAM. We have ssh access to an account with sudo, but no higher-level access in case that breaks or in case we mess up upgrading the underlying Ubuntu distribution too, which we should because it's reached its support end-of-life.

BigBlueButton's website recommends installing 2.7 on a clean, dedicated system instead of trying to do the upgrade in place. It requires a major version upgrade to at least Ubuntu 20.04, and it recommends 16 GB memory and 8 CPU cores.

System administration isn't my current cup of tea, and the other organizers might be busy.

Some choices we're thinking about are:

  • Continue with our current 2.2 setup, just hack better layouts into it with Tampermonkey or something: probably not a very good choice from the perspective of being a good citizen of the Internet, since the system's out of date
  • Try to upgrade in place and hope we don't break anything: one of the other organizers is willing to add this to his maybe-do list
  • Install 2.7 on a new node, try to migrate to it to figure out the process, and then maybe consider spinning up a new node during EmacsConf, adding it to our hosting costs budget
  • Pay for BigBlueButton hosting: might be worth it if no one wants to take on the responsibility for managing BBB ourselves
  • Switch to hosted Jitsi: recording might be trickier

Commercial BigBlueButton hosts tend to charge based on the number of simultaneous users and the number of rooms.

It's been nice having one room per group of speakers because then we can e-mail speakers their personal URL for testing and checking in, the scripts can join the correct room automatically, we never have to worry about time, and all the recordings are split up. In previous years, we rotated among a set of five rooms, but then we needed to keep track of who was using which rooms. I think going with multiple rooms makes sense.

So it mostly comes down to the number of simultaneous users. I rsynced /var/bbb/recording/raw and cross-referenced each talk with its BBB meeting using slugs I'd added to the meeting title, disambiguating them as needed. Then I could use the following function from emacsconf-extract.el:

Report on simultaneous users
(defun emacsconf-extract-bbb-report ()
  (let* ((max 0)
         (participant-count 0)
         (meeting-count 0)
         (max-meetings 0)
         (max-participants 0)
         meeting-participants
         (meeting-events
          (sort
           (seq-mapcat
            (lambda (talk)
              (when (plist-get talk :bbb-meeting-id)
                (let ((dom (xml-parse-file (emacsconf-extract-bbb-raw-events-file-name talk)))
                      participants talking meeting-events)
                  (mapc (lambda (o)
                          (pcase (dom-attr o 'eventname)
                            ("ParticipantJoinEvent"
                             (cl-pushnew (cons (dom-text (dom-by-tag o 'userId))
                                               (dom-text (dom-by-tag o 'name)))
                                         participants)
                             (push (cons (string-to-number (dom-text (dom-by-tag o 'timestampUTC)))
                                         (dom-attr o 'eventname))
                                   meeting-events))
                            ("ParticipantLeftEvent"
                             (when (string= (dom-attr o 'module) "PARTICIPANT")
                               (push (cons (string-to-number (dom-text (dom-by-tag o 'timestampUTC)))
                                           (dom-attr o 'eventname))
                                     meeting-events)))
                            ("ParticipantTalkingEvent"
                             (cl-pushnew (assoc-default (dom-text (dom-by-tag o 'participant)) participants) talking))
                            ((or
                              "CreatePresentationPodEvent"
                              "EndAndKickAllEvent")
                             (push (cons (string-to-number (dom-text (dom-by-tag o 'timestampUTC)))
                                         (dom-attr o 'eventname))
                                   meeting-events))))
                        (dom-search dom (lambda (o) (dom-attr o 'eventname))))
                  (cl-pushnew (list :slug (plist-get talk :slug)
                                    :participants participants
                                    :talking talking)
                              meeting-participants)
                  meeting-events)))
            (emacsconf-get-talk-info))
           (lambda (a b) (< (car a) (car b))))))
    (dolist (event meeting-events)
      (pcase (cdr event)
        ("CreatePresentationPodEvent" (cl-incf meeting-count) (when (> meeting-count max-meetings) (setq max-meetings meeting-count)))
        ("ParticipantJoinEvent" (cl-incf participant-count) (when (> participant-count max-participants) (setq max-participants participant-count)))
        ("ParticipantLeftEvent" (cl-decf participant-count))
        ("EndAndKickAllEvent" (cl-decf meeting-count))))
    `((,(length meeting-participants) "Number of meetings analyzed")
      (,max-participants "Max number of simultaneous users")
      (,max-meetings "Max number of simultaneous meetings")
      (,(apply 'max (mapcar (lambda (o) (length (plist-get o :participants))) meeting-participants)) "Max number of people in one meeting")
      (,(length (seq-uniq (seq-mapcat (lambda (o) (mapcar #'cdr (plist-get o :participants))) meeting-participants))) "Total unique users")
      (,(length (seq-uniq (seq-mapcat (lambda (o) (plist-get o :talking)) meeting-participants))) "Total unique talking"))))

31 Number of meetings analyzed
62 Max number of simultaneous users
6 Max number of simultaneous meetings
27 Max number of people in one meeting
84 Total unique users
36 Total unique talking

The number of simultaneous users is pretty manageable. Most people watch the stream, which we broadcast via Icecast, so those numbers aren't reflected here. I think we tended to have between 100-200 viewers on Icecast.

For that kind of usage, some hosting options are:

  • BigBlueButton hosting:

    Host Monthly Concurrent users Notes
    BiggerBlueButton USD 40 150 I'd need to check if we can have more than 10 created rooms if only at most 10 are used concurrently
    Web Hosting Zone USD 49 100  
    Myna Parrot USD 60 75 USD 150/month + USD 15 setup fee if we want to use our own URL
    BigBlueButton.host USD 85 80  
    BigBlueMeeting USD 125 100  
    BBB On Demand     8 vCPU 32 GB RAM: USD 1.20/hour, USD 0.05/hour when stopped: USD 86 for 3 days
    BBB On Demand   100 USD 2.40/hour: USD 173 for 3 days
  • Virtual private server: We'd need to set up and manage this ourselves. We could probably run it for one week before to give speakers time to do their tech-checks and one week after to give me time to pull the recordings. The other servers are on Linode, so it might make sense to keep it there too and manage it all in one place.

    Type Monthly  
    dedicated 8 GB 4-core USD 72 USD 0.108/hour, so USD 36 if we run it for two weeks
    dedicated CPU 16 GB 8-core USD 144 USD 0.216/hour, so USD 72 if we run it for two weeks

It would be nice if we could just do the upgrade and get it back onto our current server (also, fixing up our current server with a proper SMTP setup so that it could send out things like password reminder emails), although the current BigBlueButton server was donated by a defunct organization so it might be a good idea to have a backup plan for it anyway.

It would also be nice to add it to our Ansible configuration so that we could install BigBlueButton that way, maybe based on ansible-role-bigbluebutton. But again, not my current cup of tea, so it will need to wait until someone can step up to do it or I get around to it.

The Free Software Foundation feels strongly about software as a service substitute. They're okay with virtual private servers, but I'm not sure how far their moral objection goes when it comes to using and paying for free/libre/opensource software as a service, like BigBlueButton. I'm personally okay with paying for services, especially if they're based on free software. Since EmacsConf is committed to using free software and not requiring people to use non-free software, that might be something the other organizers can weigh in on. If someone feels strongly enough about it, maybe they'll work on it. I think it can be hard enough for people to find the time for stuff they like, so if no one particularly likes doing this sort of stuff, I'm okay with scaling down or paying for something that's ready to go.

Anyway, at least we have the numbers for decisions!

View org source for this post

EmacsConf backstage: Using Spookfox to publish YouTube and Toobnix video drafts

| emacsconf, emacs, spookfox, youtube, video

I ran into quota limits when uploading videos to YouTube with a command-line tool, so I uploaded videos by selecting up to 15 videos at a time using the web-based interface. Each video was a draft, though, and I was having a hard time updating its visibility through the API. I think it eventually worked, but in the meantime, I used this very hacky hack to look for the "Edit Draft" button and click through the screens to publish them.

emacsconf-extract-youtube-publish-video-drafts-with-spookfox: Look for drafts and publish them.
(defun emacsconf-extract-youtube-publish-video-drafts-with-spookfox ()
  "Look for drafts and publish them."
  (while (not (eq (spookfox-js-injection-eval-in-active-tab
                   "document.querySelector('.edit-draft-button div') != null" t) :false))
    (progn
      (spookfox-js-injection-eval-in-active-tab
       "document.querySelector('.edit-draft-button div').click()" t)
      (sleep-for 2)
      (spookfox-js-injection-eval-in-active-tab
       "document.querySelector('#step-title-3').click()" t)
      (when (spookfox-js-injection-eval-in-active-tab
             "document.querySelector('tp-yt-paper-radio-button[name=\"PUBLIC\"] #radioLabel').click()" t)
        (spookfox-js-injection-eval-in-active-tab
         "document.querySelector('#done-button').click()" t)
        (while (not (eq  (spookfox-js-injection-eval-in-active-tab
                          "document.querySelector('#close-button .label') == null" t)
                         :false))
          (sleep-for 1))

        (spookfox-js-injection-eval-in-active-tab
         "document.querySelector('#close-button .label').click()" t)
        (sleep-for 1)))))

Another example of a hacky Spookfox workaround was publishing the unlisted videos. I couldn't figure out how to properly authenticate with the Toobnix (Peertube) API to change the visibility of videos. Peertube uses AngularJS components in the front end, so using .click() on the input elements didn't seem to trigger anything. I found out that I needed to use .dispatchEvent(new Event('input')) to tell the dropdown for the visibility to display the options. source

emacsconf-extract-toobnix-publish-video-from-edit-page: Messy hack to set a video to public and store the URL.
(defun emacsconf-extract-toobnix-publish-video-from-edit-page ()
  "Messy hack to set a video to public and store the URL."
  (interactive)
  (spookfox-js-injection-eval-in-active-tab "document.querySelector('label[for=privacy]').scrollIntoView(); document.querySelector('label[for=privacy]').closest('.form-group').querySelector('input').dispatchEvent(new Event('input'));" t)
  (sit-for 1)
  (spookfox-js-injection-eval-in-active-tab "document.querySelector('span[title=\"Anyone can see this video\"]').click()" t)
  (sit-for 1)
  (spookfox-js-injection-eval-in-active-tab "document.querySelector('button.orange-button').click()" t)(sit-for 3)
  (emacsconf-extract-store-url)
  (shell-command "xdotool key Alt+Tab sleep 1 key Ctrl+w Alt+Tab"))

It's a little nicer using Spookfox to automate browser interactions than using xdotool, since I can get data out of it too. I could also have used Puppeteer from either Python or NodeJS, but it's nice staying with Emacs Lisp. Spookfox has some Javascript limitations (can't close windows, etc.), so I might still use bits of xdotool or Puppeteer to work around that. Still, it's nice to now have an idea of how to talk to AngularJS components.

EmacsConf backstage: Making a (play)list, checking it twice

| emacs, emacsconf, spookfox, youtube, video

I wanted the EmacsConf 2023 Youtube and Toobnix playlists to mostly reflect the schedule of the conference by track, with talks followed by their Q&A sessions (if recorded).

The list

I used Emacs Lisp to generate a list of videos in the order I wanted. That Sunday closing remarks aren't actually in the playlists because they're combined with the Q&A for my session on how we run Emacsconf.

emacsconf-extract-check-playlists: Return a table for checking playlist order.
(defun emacsconf-extract-check-playlists ()
  "Return a table for checking playlist order."
  (let ((pos 0))
    (seq-mapcat (lambda (o)
                  (delq
                   nil
                   (list
                    (when (emacsconf-talk-file o "--main.webm")
                      (cl-incf pos)
                      (list pos
                            (plist-get o :title)
                            (org-link-make-string
                             (plist-get o :youtube-url)
                             "YouTube")
                            (org-link-make-string
                             (plist-get o :toobnix-url)
                             "Toobnix")))
                    (when (emacsconf-talk-file o "--answers.webm")
                      (cl-incf pos)
                      (list pos (concat "Q&A: " (plist-get o :title))
                            (org-link-make-string
                             (plist-get o :qa-youtube-url)
                             "YouTube")
                            (org-link-make-string
                             (plist-get o :qa-toobnix-url)
                             "Toobnix"))))))
                (emacsconf-publish-prepare-for-display (emacsconf-get-talk-info)))))

1 An Org-Mode based text adventure game for learning the basics of Emacs, inside Emacs, written in Emacs Lisp YouTube Toobnix
2 Authoring and presenting university courses with Emacs and a full libre software stack YouTube Toobnix
3 Q&A: Authoring and presenting university courses with Emacs and a full libre software stack YouTube Toobnix
4 Teaching computer and data science with literate programming tools YouTube Toobnix
5 Q&A: Teaching computer and data science with literate programming tools YouTube Toobnix
6 Who needs Excel? Managing your students qualifications with org-table YouTube Toobnix
7 one.el: the static site generator for Emacs Lisp Programmers YouTube Toobnix
8 Q&A: one.el: the static site generator for Emacs Lisp Programmers YouTube Toobnix
9 Emacs turbo-charges my writing YouTube Toobnix
10 Q&A: Emacs turbo-charges my writing YouTube Toobnix
11 Why Nabokov would use Org-Mode if he were writing today YouTube Toobnix
12 Q&A: Why Nabokov would use Org-Mode if he were writing today YouTube Toobnix
13 Collaborative data processing and documenting using org-babel YouTube Toobnix
14 How I play TTRPGs in Emacs YouTube Toobnix
15 Q&A: How I play TTRPGs in Emacs YouTube Toobnix
16 Org-Mode workflow: informal reference tracking YouTube Toobnix
17 (Un)entangling projects and repos YouTube Toobnix
18 Emacs development updates YouTube Toobnix
19 Emacs core development: how it works YouTube Toobnix
20 Top 10 ways Hyperbole amps up Emacs YouTube Toobnix
21 Using Koutline for stream of thought journaling YouTube Toobnix
22 Parallel text replacement YouTube Toobnix
23 Q&A: Parallel text replacement YouTube Toobnix
24 Eat and Eat powered Eshell, fast featureful terminal inside Emacs YouTube Toobnix
25 The browser in a buffer YouTube Toobnix
26 Speedcubing in Emacs YouTube Toobnix
27 Emacs MultiMedia System (EMMS) YouTube Toobnix
28 Q&A: Emacs MultiMedia System (EMMS) YouTube Toobnix
29 Programming with steno YouTube Toobnix
30 Mentoring VS-Coders as an Emacsian (or How to show not tell people about the wonders of Emacs) YouTube Toobnix
31 Q&A: Mentoring VS-Coders as an Emacsian (or How to show not tell people about the wonders of Emacs) YouTube Toobnix
32 Emacs saves the Web (maybe) YouTube Toobnix
33 Q&A: Emacs saves the Web (maybe) YouTube Toobnix
34 Sharing Emacs is Caring Emacs: Emacs education and why I embraced video YouTube Toobnix
35 Q&A: Sharing Emacs is Caring Emacs: Emacs education and why I embraced video YouTube Toobnix
36 MatplotLLM, iterative natural language data visualization in org-babel YouTube Toobnix
37 Enhancing productivity with voice computing YouTube Toobnix
38 Q&A: Enhancing productivity with voice computing YouTube Toobnix
39 LLM clients in Emacs, functionality and standardization YouTube Toobnix
40 Q&A: LLM clients in Emacs, functionality and standardization YouTube Toobnix
41 Improving compiler diagnostics with overlays YouTube Toobnix
42 Q&A: Improving compiler diagnostics with overlays YouTube Toobnix
43 Editor Integrated REPL Driven Development for all languages YouTube Toobnix
44 REPLs in strange places: Lua, LaTeX, LPeg, LPegRex, TikZ YouTube Toobnix
45 Literate Documentation with Emacs and Org Mode YouTube Toobnix
46 Q&A: Literate Documentation with Emacs and Org Mode YouTube Toobnix
47 Windows into Freedom YouTube Toobnix
48 Bringing joy to Scheme programming YouTube Toobnix
49 Q&A: Bringing joy to Scheme programming YouTube Toobnix
50 GNU Emacs: A World of Possibilities YouTube Toobnix
51 Q&A: GNU Emacs: A World of Possibilities YouTube Toobnix
52 A modern Emacs look-and-feel without pain YouTube Toobnix
53 The Emacsen family, the design of an Emacs and the importance of Lisp YouTube Toobnix
54 Q&A: The Emacsen family, the design of an Emacs and the importance of Lisp YouTube Toobnix
55 emacs-gc-stats: Does garbage collection actually slow down Emacs? YouTube Toobnix
56 Q&A: emacs-gc-stats: Does garbage collection actually slow down Emacs? YouTube Toobnix
57 hyperdrive.el: Peer-to-peer filesystem in Emacs YouTube Toobnix
58 Q&A: hyperdrive.el: Peer-to-peer filesystem in Emacs YouTube Toobnix
59 Writing a language server in OCaml for Emacs, fun, and profit YouTube Toobnix
60 Q&A: Writing a language server in OCaml for Emacs, fun, and profit YouTube Toobnix
61 What I learned by writing test cases for GNU Hyperbole YouTube Toobnix
62 Q&A: What I learned by writing test cases for GNU Hyperbole YouTube Toobnix
63 EmacsConf.org: How we use Org Mode and TRAMP to organize and run a multi-track conference YouTube Toobnix
64 Q&A: EmacsConf.org: How we use Org Mode and TRAMP to organize and run a multi-track conference YouTube Toobnix
65 Saturday opening remarks YouTube Toobnix
66 Saturday closing remarks YouTube Toobnix
67 Sunday opening remarks YouTube Toobnix
68 Sunday closing remarks YouTube Toobnix

YouTube

I bulk-added the Youtube videos to the playlist. The videos were not in order because I uploaded some late submissions and forgotten videos, which then got added to the end of the list.

I tried using the API to sort the playlist. This got it most of the way there, and then I sorted the rest by hand.

emacsconf-extract-youtube-api-sort-playlist: Try to roughly sort the playlist.
(defun emacsconf-extract-youtube-api-sort-playlist (&optional dry-run-only)
  "Try to roughly sort the playlist."
  (interactive)
  (setq emacsconf-extract-youtube-api-playlist (seq-find (lambda (o) (let-alist o (string= .snippet.title (concat emacsconf-name " " emacsconf-year))))
                                        (assoc-default 'items emacsconf-extract-youtube-api-playlists)))
  (setq emacsconf-extract-youtube-api-playlist-items
        (emacsconf-extract-youtube-api-paginated-request (concat "https://youtube.googleapis.com/youtube/v3/playlistItems?part=snippet,contentDetails,status&forMine=true&order=date&maxResults=100&playlistId="
                                                (url-hexify-string (assoc-default 'id emacsconf-extract-youtube-api-playlist)))))
  (let* ((playlist-info emacsconf-extract-youtube-api-playlists)
         (playlist-items emacsconf-extract-youtube-api-playlist-items)
         (info (emacsconf-publish-prepare-for-display (emacsconf-get-talk-info)))
         (slugs (seq-map (lambda (o) (plist-get o :slug)) info))
         (position (1- (length playlist-items)))
         result)
    ;; sort items
    (mapc (lambda (talk)
            (when (plist-get talk :qa-youtube-id)
              ;; move the q & a
              (let ((video-object (emacsconf-extract-youtube-find-url-video-in-list
                                   (plist-get talk :qa-youtube-url)
                                   playlist-items)))
                (let-alist video-object
                  (cond
                   ((null video-object)
                    (message "Could not find video for %s" (plist-get talk :slug)))
                   ;; not in the right position, try to move it
                   ((< .snippet.position position)
                    (let ((video-id .id)
                          (playlist-id .snippet.playlistId)
                          (resource-id .snippet.resourceId))
                      (message "Trying to move %s Q&A to %d from %d" (plist-get talk :slug) position .snippet.position)
                      (add-to-list 'result (list (plist-get talk :slug) "answers" .snippet.position position))
                      (unless dry-run-only
                        (plz 'put "https://www.googleapis.com/youtube/v3/playlistItems?part=snippet"
                          :headers `(("Authorization" . ,(url-oauth-auth "https://youtube.googleapis.com/youtube/v3/"))
                                     ("Accept" . "application/json")
                                     ("Content-Type" . "application/json"))
                          :body (json-encode
                                 `((id . ,video-id)
                                   (snippet
                                    (playlistId . ,playlist-id)
                                    (resourceId . ,resource-id)
                                    (position . ,position))))))))))
                (setq position (1- position))))
            ;; move the talk if needed
            (let ((video-object
                   (emacsconf-extract-youtube-find-url-video-in-list
                    (plist-get talk :youtube-url)
                    playlist-items)))
              (let-alist video-object
                (cond
                 ((null video-object)
                  (message "Could not find video for %s" (plist-get talk :slug)))
                 ;; not in the right position, try to move it
                 ((< .snippet.position position)
                  (let ((video-id .id)
                        (playlist-id .snippet.playlistId)
                        (resource-id .snippet.resourceId))
                    (message "Trying to move %s to %d from %d" (plist-get talk :slug) position .snippet.position)
                    (add-to-list 'result (list (plist-get talk :slug) "main" .snippet.position position))
                    (unless dry-run-only
                      (plz 'put "https://www.googleapis.com/youtube/v3/playlistItems?part=snippet"
                        :headers `(("Authorization" . ,(url-oauth-auth "https://youtube.googleapis.com/youtube/v3/"))
                                   ("Accept" . "application/json")
                                   ("Content-Type" . "application/json"))
                        :body (json-encode
                               `((id . ,video-id)
                                 (snippet
                                  (playlistId . ,playlist-id)
                                  (resourceId . ,resource-id)
                                  (position . ,position))))))
                    ))))
              (setq position (1- position))))
          (nreverse info))
    result))

I needed to sort some of the videos manually. Trying to scroll by dragging items to the top of the currently-displayed section of the list was slow, and dropping the item near the top of the list so that I could pick it up again after paging up was a little disorienting. Fortunately, keyboard scrolling with page-up and page-down worked even while dragging an item, so that was what I ended up doing: select the item and then page-up while dragging.

YouTube doesn't display numbers for the playlist positions, but this will add them. The numbers don't dynamically update when the list is reordered, so I just re-ran the code after moving things around.

emacsconf-extract-youtube-spookfox-add-playlist-numbers: Number the playlist for easier checking.
(defun emacsconf-extract-youtube-spookfox-add-playlist-numbers ()
  "Number the playlist for easier checking.
Related: `emacsconf-extract-check-playlists'."
  (interactive)
  (spookfox-js-injection-eval-in-active-tab "[...document.querySelectorAll('ytd-playlist-video-renderer')].forEach((o, i) => { o.querySelector('.number')?.remove(); let div = document.createElement('div'); div.classList.add('number'); div.textContent = i; o.prepend(div) }))" t))

2023-12-11_12-57-25.png
Figure 1: Adding numbers to the Youtube playlist

In retrospect, I could probably have just cleared the playlist and then added the videos using the in the right order instead of fiddling with inserting things.

Toobnix (Peertube)

Toobnix (Peertube) doesn't seem to have a way to bulk-add videos to a playlist (or even to bulk-set their visibility). I started trying to figure out how to use the API, but I got stuck because my token didn't seem to let me access unlisted videos or do other things that required proper authentication. Anyway, I came up with this messy hack to open the videos in sequence and add them to the playlist using Spookfox.

(defun emacsconf-extract-toobnix-set-up-playlist ()
  (interactive)
  (mapcar
   (lambda (o)
     (when (plist-get o :toobnix-url)
       (browse-url (plist-get o :toobnix-url))
       (read-key "press a key when page is loaded")
       (spookfox-js-injection-eval-in-active-tab "document.querySelector('.action-button-save').click()" t)
       (spookfox-js-injection-eval-in-active-tab "document.querySelector('my-peertube-checkbox').click()" t)
       (read-key "press a key when saved to playlist"))
     (when (plist-get o :qa-toobnix-url)
       (browse-url (plist-get o :qa-toobnix-url))
       (read-key "press a key when page is loaded")
       (spookfox-js-injection-eval-in-active-tab "document.querySelector('.action-button-save').click()" t)
       (spookfox-js-injection-eval-in-active-tab "document.querySelector('my-peertube-checkbox').click()" t)
       (read-key "press a key when saved to playlist")))
   (emacsconf-publish-prepare-for-display (emacsconf-get-talk-info))))

Maybe next year, I might be able to figure out how to use the APIs to do this stuff automatically.

This code is in emacsconf-extract.el.

Updating YouTube videos via the YouTube Data API using Emacs Lisp and url-http-oauth

| elisp, emacs, emacsconf, youtube, video

We upload EmacsConf videos to both YouTube and Toobnix, which is a PeerTube instance. This makes it easier for people to come across them after the conference.

I can upload to Toobnix and set titles and descriptions using the peertube-cli tool. I tried a Python script for uploading to YouTube, but it was a bit annoying due to quota restrictions. Instead, I uploaded the videos by dragging and dropping them into YouTube Studio. This allowed me to upload 15 at a time.

The videos on YouTube had just the filenames. I wanted to rename the videos and set the descriptions. In 2022, I used xdotool, simulating mouse clicks and pasting in text for larger text blocks.

Xdotool script
(defun my-xdotool-insert-mouse-location
    (interactive)
  (let ((pos (shell-command-to-string "xdotool getmouselocation")))
    (when (string-match "x:\\([0-9]+\\) y:\\([0-9]+\\)" pos)
      (insert (format "(shell-command \"xdotool mousemove %s %s click 1\")\n" (match-string 1 pos) (match-string 2 pos))))))

(setq list (seq-filter (lambda (o)
                         (and
                          (file-exists-p
                           (expand-file-name
                            (concat (plist-get o :video-slug) "--final.webm")
                            emacsconf-cache-dir))
                          (null (plist-get o :youtube-url))))
            (emacsconf-publish-prepare-for-display (emacsconf-get-talk-info))))

(while list
  (progn
    (shell-command "xdotool mousemove 707 812 click 1 sleep 2")

    (setq talk (pop list))
    ;; click create
    (shell-command "xdotool mousemove 843 187 click 1 sleep 1")
    ;; video
    (shell-command "xdotool mousemove 833 217 click 1 sleep 1")
    ;; select files
    (shell-command (concat "xdotool mousemove 491 760 click 1 sleep 4 type "
                           (shell-quote-argument (concat (plist-get talk :video-slug) "--final.webm"))))
    ;; open
    (shell-command "xdotool mousemove 1318 847 click 1 sleep 5")

    (kill-new (concat
               emacsconf-name " "
               emacsconf-year ": "
               (plist-get talk :title)
               " - "
               (plist-get talk :speakers-with-pronouns)))
    (shell-command "xdotool sleep 1 mousemove 331 440 click :1 key Ctrl+a Delete sleep 1 key Ctrl+Shift+v sleep 2")

    (kill-new (emacsconf-publish-video-description talk t))
    (shell-command "xdotool mousemove 474 632 click 1 sleep 1 key Ctrl+a sleep 1 key Delete sleep 1 key Ctrl+Shift+v"))
  (read-string "Press a key once you've pasted in the description")

  ;; next
  (when (emacsconf-captions-edited-p (expand-file-name (concat (plist-get talk :video-slug) "--main.vtt") emacsconf-cache-dir))
    (shell-command "xdotool mousemove 352 285 click 1 sleep 1")

    ;; add captions
    (shell-command "xdotool mousemove 877 474 click 1 sleep 3")
    (shell-command "xdotool mousemove 165 408 click 1 sleep 1")
    (shell-command "xdotool mousemove 633 740 click 1 sleep 2")
    (shell-command (concat "xdotool mousemove 914 755  click 1 sleep 4 type "
                           (shell-quote-argument (concat (plist-get talk :video-slug) "--main.vtt"))))
    (read-string "Press a key once you've loaded the VTT")
    (shell-command "xdotool mousemove 910 1037 sleep 1 click 1 sleep 4")
    ;; done
    (shell-command "xdotool mousemove 890 297 click 1 sleep 3")
    )


  (progn
    ;; visibility
    (shell-command "xdotool mousemove 810 303 click 1 sleep 2")
    ;; public
    (shell-command "xdotool mousemove 119 614 click 1 sleep 2")
    ;; copy
    (shell-command "xdotool mousemove 882 669 click 1 sleep 1")
    ;; done
    (shell-command "xdotool mousemove 908 1089 click 1 sleep 5 key Alt+Tab")

    (emacsconf-with-talk-heading talk
      (org-entry-put (point) "YOUTUBE_URL" (read-string "URL: "))
      ))
  )

Using xdotool wasn't very elegant, since I needed to figure out the coordinates for each click. I tried using Spookfox to control Mozilla Firefox from Emacs, but Youtube's editing interface didn't seem to have any textboxes that I could set. I decided to use EmacsConf 2023 as an excuse to learn how to talk to the Youtube Data API, which required figuring out OAuth. Even though it was easy to find examples in Python and NodeJS, I wanted to see if I could stick with using Emacs Lisp so that I could add the code to the emacsconf-el repository.

After a quick search, I picked url-http-oauth as the library that I'd try first. I used the url-http-oauth-demo.el included in the package to figure out what to set for the YouTube Data API. I wrote a function to make getting the redirect URL easier (emacsconf-extract-oauth-browse-and-prompt). Once I authenticated successfully, I explored using alphapapa's plz library. It can handle finding the JSON object and parsing it out for me. With it, I updated videos to include titles and descriptions from my Emacs code, and I copied the video IDs into my Org properties.

emacsconf-extract.el code for Youtube renaming

;;; YouTube

;; When the token needs refreshing, delete the associated lines from
;; ~/.authinfo This code just sets the title and description. Still
;; need to figure out how to properly set the license, visibility,
;; recording date, and captions.
;;
;; To avoid being prompted for the client secret, it's helpful to have a line in ~/.authinfo or ~/.authinfo.gpg with
;; machine https://oauth2.googleapis.com/token username CLIENT_ID password CLIENT_SECRET

(defvar emacsconf-extract-google-client-identifier nil)
(defvar emacsconf-extract-youtube-api-channels nil)
(defvar emacsconf-extract-youtube-api-categories nil)

(defun emacsconf-extract-oauth-browse-and-prompt (url)
  "Open URL and wait for the redirected code URL."
  (browse-url url)
  (read-from-minibuffer "Paste the redirected code URL: "))

(defun emacsconf-extract-youtube-api-setup ()
  (interactive)
  (require 'plz)
  (require 'url-http-oauth)
  (when (getenv "GOOGLE_APPLICATION_CREDENTIALS")
    (let-alist (json-read-file (getenv "GOOGLE_APPLICATION_CREDENTIALS"))
      (setq emacsconf-extract-google-client-identifier .web.client_id)))
  (unless (url-http-oauth-interposed-p "https://youtube.googleapis.com/youtube/v3/")
    (url-http-oauth-interpose
     `(("client-identifier" . ,emacsconf-extract-google-client-identifier)
       ("resource-url" . "https://youtube.googleapis.com/youtube/v3/")
       ("authorization-code-function" . emacsconf-extract-oauth-browse-and-prompt)
       ("authorization-endpoint" . "https://accounts.google.com/o/oauth2/v2/auth")
       ("authorization-extra-arguments" .
        (("redirect_uri" . "http://localhost:8080")))
       ("access-token-endpoint" . "https://oauth2.googleapis.com/token")
       ("scope" . "https://www.googleapis.com/auth/youtube")
       ("client-secret-method" . prompt))))
  (setq emacsconf-extract-youtube-api-channels
        (plz 'get "https://youtube.googleapis.com/youtube/v3/channels?part=contentDetails&mine=true"
          :headers `(("Authorization" . ,(url-oauth-auth "https://youtube.googleapis.com/youtube/v3/")))
          :as #'json-read))
  (setq emacsconf-extract-youtube-api-categories
        (plz 'get "https://youtube.googleapis.com/youtube/v3/videoCategories?part=snippet&regionCode=CA"
          :headers `(("Authorization" . ,(url-oauth-auth "https://youtube.googleapis.com/youtube/v3/")))
          :as #'json-read))
  (setq emacsconf-extract-youtube-api-videos
        (plz 'get (concat "https://youtube.googleapis.com/youtube/v3/playlistItems?part=snippet,contentDetails,status&forMine=true&order=date&maxResults=50&playlistId="
                          (url-hexify-string
                           (let-alist (elt (assoc-default 'items emacsconf-extract-youtube-api-channels) 0)
                             .contentDetails.relatedPlaylists.uploads)
                           ))
          :headers `(("Authorization" . ,(url-oauth-auth "https://youtube.googleapis.com/youtube/v3/")))
          :as #'json-read)))

(defvar emacsconf-extract-youtube-tags '("emacs" "emacsconf"))
(defun emacsconf-extract-youtube-object (video-id talk &optional privacy-status)
  "Format the video object for VIDEO-ID using TALK details."
  (setq privacy-status (or privacy-status "unlisted"))
  (let ((properties (emacsconf-publish-talk-video-properties talk 'youtube)))
    `((id . ,video-id)
      (kind . "youtube#video")
      (snippet
       (categoryId . "28")
       (title . ,(plist-get properties :title))
       (tags . ,emacsconf-extract-youtube-tags)
       (description . ,(plist-get properties :description))
       ;; Even though I set recordingDetails and status, it doesn't seem to stick.
       ;; I'll leave this in here in case someone else can figure it out.
       (recordingDetails (recordingDate . ,(format-time-string "%Y-%m-%dT%TZ" (plist-get talk :start-time) t))))
      (status (privacyStatus . "unlisted")
              (license . "creativeCommon")))))

(defun emacsconf-extract-youtube-api-update-video (video-object)
  "Update VIDEO-OBJECT."
  (let-alist video-object
    (let* ((slug (cond
                  ;; not yet renamed
                  ((string-match (rx (literal emacsconf-id) " " (literal emacsconf-year) " "
                                     (group (1+ (or (syntax word) "-")))
                                     "  ")
                                 .snippet.title)
                   (match-string 1 .snippet.title))
                  ;; renamed, match the description instead
                  ((string-match (rx (literal emacsconf-base-url) (literal emacsconf-year) "/talks/"
                                     (group (1+ (or (syntax word) "-"))))
                                 .snippet.description)
                   (match-string 1 .snippet.description))
                  ;; can't find, prompt
                  (t
                   (when (string-match (rx (literal emacsconf-id) " " (literal emacsconf-year))
                                       .snippet.title)
                     (completing-read (format "Slug for %s: "
                                              .snippet.title)
                                      (seq-map (lambda (o) (plist-get o :slug))
                                               (emacsconf-publish-prepare-for-display (emacsconf-get-talk-info))))))))
           (video-id .snippet.resourceId.videoId)
           (id .id)
           result)
      (when slug
        ;; set the YOUTUBE_URL property
        (emacsconf-with-talk-heading slug
          (org-entry-put (point) "YOUTUBE_URL" (concat "https://www.youtube.com/watch?v=" video-id))
          (org-entry-put (point) "YOUTUBE_ID" id))
        (plz 'put "https://www.googleapis.com/youtube/v3/videos?part=snippet,recordingDetails,status"
          :headers `(("Authorization" . ,(url-oauth-auth "https://youtube.googleapis.com/youtube/v3/"))
                     ("Accept" . "application/json")
                     ("Content-Type" . "application/json"))
          :body (json-encode (emacsconf-extract-youtube-object video-id (emacsconf-resolve-talk slug))))))))

(defun emacsconf-extract-youtube-rename-videos (&optional videos)
  "Rename videos and set the YOUTUBE_URL property in the Org heading."
  (let ((info (emacsconf-get-talk-info)))
    (mapc
     (lambda (video)
       (when (string-match (rx (literal emacsconf-id) " " (literal emacsconf-year)))
         (emacsconf-extract-youtube-api-update-video video)))
     (assoc-default 'items (or videos emacsconf-extract-youtube-api-videos)))))

(provide 'emacsconf-extract)

I haven't quite figured out how to set status and recordingDetails properly. The code sets them, but they don't stick. That's okay. I think I can set those as a batch operation. It looks like I need to change visibility one by one, though, which might be a good opportunity to check the end of the video for anything that needs to be trimmed off.

I also want to figure out how to upload captions. I'm not entirely sure how to do multipart form data yet with the url library or plz. It might be nice to someday set up an HTTP server so that Emacs can handle OAuth redirects itself. I'll save that for another blog post and share my notes for now.

This code is in emacsconf-extract.el.

EmacsConf 2023 status update: stuff is happening!

| emacs, emacsconf

EmacsConf 2023 is less than a month away. Speakers have been uploading videos, captioning volunteers have been editing away, and I thiiiiink I've gotten most of the infrastructure dusted off. Exciting!

Here's where we are with regard to talk status:

Graphical view of the schedule Schedule for Saturday Saturday 9:00- 9:10 Saturday opening remarks sat-open 9:10- 9:20 An Org-Mode based text adventure game for learning the basics of Emacs, inside Emacs, written in Emacs Lisp adventure 9:30- 9:50 Authoring and presenting university courses with Emacs and a full libre software stack uni 10:05-10:25 Teaching computer and data science with literate programming tools teaching 10:40-10:50 Who needs Excel? Managing your students qualifications with org-table table 11:05-11:15 Taming things with Org Mode taming 11:30-11:50 one.el: the static site generator for Emacs Lisp Programmers one 1:00- 1:10 Emacs turbo-charges my writing writing 1:25- 1:35 Why Nabokov would use Org-Mode if he were writing today nabokov 1:50- 2:10 Collaborative data processing and documenting using org-babel collab 2:20- 2:40 How I play TTRPGs in Emacs solo 2:55- 3:15 Org-Mode workflow: informal reference tracking ref 3:25- 3:35 (Un)entangling projects and repos unentangling 3:45- 3:55 Emacs development updates devel 4:10- 4:50 Emacs core development: how it works core 5:05- 5:15 Saturday closing remarks sat-close 10:00-10:10 MatplotLLM, iterative natural language data visualization in org-babel matplotllm 10:20-10:40 Improving access to AI-assisted literate programming with voice control voice 10:55-11:15 LLM clients in Emacs, functionality and standardization llm 1:00- 1:20 Improving compiler diagnostics with Overlays overlay 1:35- 1:45 Editor Integrated REPL Driven Development for all languages eval 2:00- 2:40 REPLs in strange places: Lua, LaTeX, LPeg, LPegRex, TikZ repl 2:50- 3:30 Literate Documentation with Emacs and Org Mode doc 3:45- 4:05 EmacsConf.org: How we use Org Mode and TRAMP to organize and run a multi-track conference emacsconf 9 AM 10 AM 11 AM 12 PM 1 PM 2 PM 3 PM 4 PM 5 PM Schedule for Sunday Sunday 9:00- 9:05 Sunday opening remarks sun-open 9:05- 9:25 Top 10 ways Hyperbole amps up Emacs hyperamp 9:40-10:00 Using Koutline for stream of thought journaling koutline 10:10-10:20 Parallel Text Replacement: Does P = NP? parallel 10:35-10:45 Eat and Eat powered Eshell, fast featureful terminal inside Emacs eat 11:00-11:20 The browser in a buffer poltys 11:35-11:55 Speedcubing in Emacs cubing 1:00- 1:40 Emacs MultiMedia System (EMMS) emms 1:55- 2:25 Programming at 200 wpm steno 2:35- 2:45 Mentoring VS-Coders as an Emacsian (or How to show not tell people about the wonders of Emacs) mentor 3:10- 3:50 Emacs saves the Web web 4:05- 4:25 Sharing Emacs is Caring Emacs: Emacs education and why I embraced video sharing 4:40- 4:50 Sunday closing remarks sun-close 10:00-10:20 Bringing joy to Scheme programming scheme 10:35-10:55 GNU Emacs: A World of Possibilities world 11:10-11:20 A modern Emacs look-and-feel without pain flat 11:35-11:55 The Emacsen family, the design of an Emacs and the importance of Lisp emacsen 1:00- 1:20 emacs-gc-stats: Does garbage collection actually slow down Emacs? gc 1:35- 2:15 hyperdrive.el: Peer-to-peer filesystem in Emacs hyperdrive 2:30- 2:40 Writing a language server in OCaml for Emacs, fun, and profit lspocaml 2:55- 3:15 What I learned by writing test cases for GNU Hyperbole test 3:30- 4:10 Windows into Freedom windows 9 AM 10 AM 11 AM 12 PM 1 PM 2 PM 3 PM 4 PM 5 PM

Waiting for 26 talks (~550 minutes) out of 42 total. Talks received so far:

  • TO_ASSIGN (waiting for captioning volunteers) - 8 talk(s), 150 minutes: adventure (05:58), matplotllm (09:34), teaching (19:27), nabokov (09:51), collab (19:16), doc (42:45), scheme (21:01), emacsen (18:28)
  • TO_CAPTION - 2 talk(s), 21 minutes: eval (09:35), mentor (10:44)
  • TO_STREAM - 6 talk(s), 124 minutes: llm (20:26), writing (08:53), ref (15:04), emacsconf (15:05), world (22:20), emms (38:38)

Speakers have been really nice about keeping in touch, so I'm not too stressed about gaps in the schedule. Captioning volunteers have been chugging through the talks and OpenAI Whisper's gotten a bit better at spelling things, so that's terrific too. It's so exciting!

zaeph and bandali will probably host the general track and the development track respectively. They've done it for a number of years now, so it'll probably be fine even if we don't have a dry run all together since they've got limited availability. (And we can take on new volunteers if people want to help read questions!)

My stress level is pretty manageable at this point. I can even spend evenings playing video games with the kiddo and weekends going on little bike adventures, so that's awesome. I'm still a little worried about tech hiccups, but we'll probably be able to figure things out.

Next steps are:

  • keep processing videos and captions
  • make the intro videos available so that speakers can correct my pronunciation of their names
  • smoothen out and document the process for last-minute submissions
  • test everything again

It's happening!

#EmacsConf backstage: coordinating captioning volunteers using a backstage area

| emacs, emacsconf
2023-10-28_21-32-21.png
Figure 1: The backstage area

One of the benefits of volunteering for or speaking at EmacsConf is that you get early access to the talks. We upload the files to a password-protected web server. It's nothing fancy, just a directory that has basic authentication and a static index.html generated by Emacs Lisp.

I organize the talks by TODO status so that people can easily see which talks are ready to be captioned. Since captioning can take a little while, we reserve the talks we want to caption. That way, people don't accidentally work on a talk that someone else is already captioning. When people e-mail me to reserve a talk, I move the talk from TO_ASSIGN to TO_CAPTION and add their name in the :CAPTIONER: property.

Publishing the backstage index is done by an Emacs Lisp function that smooshes the information together and then writes the files directly to the server using TRAMP.

emacsconf-publish-backstage-index
(defun emacsconf-publish-backstage-index (&optional filename)
  (interactive)
  (setq filename (or filename (expand-file-name "index.html" emacsconf-backstage-dir)))
  (let ((info (or emacsconf-schedule-draft (emacsconf-publish-prepare-for-display (emacsconf-get-talk-info))))
        (emacsconf-main-extensions (append emacsconf-main-extensions emacsconf-publish-backstage-extensions)))
    (with-temp-file filename
      (let* ((talks
              (mapcar
               (lambda (o) (append
                            (list :captions-edited t) o))
               (seq-filter (lambda (o) (plist-get o :speakers))
                           (emacsconf-active-talks (emacsconf-filter-talks info)))))
             (by-status (seq-group-by (lambda (o) (plist-get o :status)) talks))
             (files (directory-files emacsconf-backstage-dir)))
        (insert
         "<html><head><meta charset=\"UTF-8\"><link rel=\"stylesheet\" href=\"/style.css\" /></head><body>"
         (if (file-exists-p (expand-file-name "include-in-index.html" emacsconf-cache-dir))
             (with-temp-buffer (insert-file-contents (expand-file-name "include-in-index.html" emacsconf-cache-dir)) (buffer-string))
           "")
         "<p>Schedule by status: (gray: waiting, light yellow: processing, yellow: to assign, light green: captioning, green: captioned and ready)<br />Updated by conf.org and the wiki repository</br />"
         (let* ((emacsconf-schedule-svg-modify-functions '(emacsconf-schedule-svg-color-by-status))
                (img (emacsconf-schedule-svg 800 200 info)))
           (with-temp-buffer
             (mapc (lambda (node)
                     (dom-set-attribute
                      node 'href
                      (concat "#" (dom-attr node 'data-slug))))
                   (dom-by-tag img 'a))
             (svg-print img)
             (buffer-string)))
         "</p>"
         (if (eq emacsconf-backstage-phase 'prerec)
             (format "<p>Waiting for %d talks (~%d minutes) out of %d total</p>"
                     (length (assoc-default "WAITING_FOR_PREREC" by-status))
                     (emacsconf-sum :time (assoc-default "WAITING_FOR_PREREC" by-status))
                     (length talks))
           "")
         "<ul>"
         (mapconcat
          (lambda (status)
            (concat "<li>"
                    (if (string= status "TO_ASSIGN")
                        "TO_ASSIGN (waiting for volunteers)"
                      status)
                    ": "
                    (mapconcat (lambda (o) (format "<a href=\"#%s\">%s</a>"
                                                   (plist-get o :slug)
                                                   (plist-get o :slug)))
                               (assoc-default status by-status)
                               ", ")
                    "</li>"))
          (pcase emacsconf-backstage-phase
            ('prerec '("TO_PROCESS" "PROCESSING" "TO_ASSIGN" "TO_CAPTION" "TO_STREAM"))
            ('harvest '("TO_ARCHIVE" "TO_REVIEW_QA" "TO_INDEX_QA" "TO_CAPTION_QA")))
          "")
         "</ul>"
         (pcase emacsconf-backstage-phase
           ('prerec
            (concat
             (emacsconf-publish-backstage-processing by-status files)
             (emacsconf-publish-backstage-to-assign by-status files)
             (emacsconf-publish-backstage-to-caption by-status files)
             (emacsconf-publish-backstage-to-stream by-status files)))
           ('harvest
            (let ((stages
                   '(("TO_REVIEW_QA" .
                      "Please review the --bbb-webcams.webm file and/or the --bbb-webcams.vtt and tell us (emacsconf-submit@gnu.org) if a Q&amp;A session can be published or if it needs to be trimmed (lots of silence at the end of the recording, accidentally included sensitive information, etc.).")
                     ("TO_INDEX_QA" .
                      "Please review the --answers.webm and --answers.vtt files to make chapter markers so that people can jump to specific parts of the Q&amp;A session. The <a href=\"https://emacsconf.org/harvesting/\">harvesting page on the wiki</a> has some notes on the process. That way, it's easier for people to see the questions and
answers without needing to listen to everything again. You can see <a href=\"https://emacsconf.org/2022/talks/asmblox\">asmblox</a> for an example of the Q&amp;A chapter markers.")
                     ("TO_CAPTION_QA" .
                      "Please edit the --answers.vtt for the Q&amp;A talk you're interested in, correcting misrecognized words and cleaning it up so that it's nice to use as closed captions. All talks should now have large-model VTTs to make it easier to edit."))))
              (mapconcat
               (lambda (stage)
                 (let ((status (car stage)))
                   (format
                    "<h1>%s: %d talk(s) (%d minutes)</h1>%s<ol class=\"videos\">%s</ol>"
                    status
                    (length (assoc-default status by-status))
                    (emacsconf-sum :video-time (assoc-default status by-status))
                    (cdr stage)
                    (mapconcat
                     (lambda (f)
                       (format  "<li><a name=\"%s\"></a><strong><a href=\"%s%s\">%s</a></strong><br />%s (id:%s)<br />%s</li>"
                                (plist-get f :slug)
                                emacsconf-base-url
                                (plist-get f :url)
                                (plist-get f :title)
                                (plist-get f :speakers-with-pronouns)
                                (plist-get f :slug)
                                (emacsconf-publish-index-card
                                 (append (list
                                          :video-note
                                          (unless (file-exists-p (expand-file-name (concat (plist-get f :file-prefix) "--bbb-webcams.webm") emacsconf-cache-dir))
                                            "<div>No Q&A video for this talk</div>")
                                          :video-file
                                          (cond
                                           ((file-exists-p (expand-file-name (concat (plist-get f :file-prefix) "--answers.webm") emacsconf-cache-dir))
                                            (concat (plist-get f :file-prefix) "--answers.webm"))
                                           ((file-exists-p (expand-file-name (concat (plist-get f :file-prefix) "--bbb-webcams.webm") emacsconf-cache-dir))
                                            (concat (plist-get f :file-prefix) "--bbb-webcams.webm"))
                                           (t t)) ;; omit video
                                          :video-id "-qanda"
                                          :extra
                                          (concat
                                           (emacsconf-surround "QA note: " (plist-get f :qa-note) "<br />")
                                           (format "Q&A archiving: <a href=\"%s-%s.txt\">IRC: %s-%s</a>"
                                                   (format-time-string "%Y-%m-%d" (plist-get f :start-time))
                                                   (plist-get (emacsconf-get-track f) :channel)
                                                   (format-time-string "%Y-%m-%d" (plist-get f :start-time))
                                                   (plist-get (emacsconf-get-track f) :channel))
                                           (emacsconf-surround ", <a href=\""
                                                               (if (file-exists-p (expand-file-name (concat (plist-get f :file-prefix) "--pad.txt")
                                                                                                    emacsconf-cache-dir))
                                                                   (concat (plist-get f :file-prefix) "--pad.txt"))
                                                               "\">Etherpad (Markdown)</a>" "")
                                           (emacsconf-surround ", <a href=\"" (plist-get f :bbb-playback) "\">BBB playback</a>" "")
                                           (emacsconf-surround ", <a href=\""
                                                               (if (file-exists-p (expand-file-name (concat (plist-get f :file-prefix) "--bbb.txt")
                                                                                                    emacsconf-cache-dir))
                                                                   (concat (plist-get f :file-prefix) "--bbb.txt"))
                                                               "\">BBB text chat</a>" "")
                                           (emacsconf-surround ", <a href=\""
                                                               (if (file-exists-p (expand-file-name (concat (plist-get f :file-prefix) "--bbb-webcams.opus")
                                                                                                    emacsconf-cache-dir))
                                                                   (concat (plist-get f :file-prefix) "--bbb-webcams.opus"))
                                                               "\">BBB audio only</a>" ""))
                                          :files (emacsconf-publish-talk-files f files))
                                         f)
                                 emacsconf-main-extensions)))
                     (assoc-default status by-status) "\n"))))
               stages
               "\n"))))
         (if (file-exists-p (expand-file-name "include-in-index-footer.html" emacsconf-cache-dir))
             (with-temp-buffer (insert-file-contents (expand-file-name "include-in-index-footer.html" emacsconf-cache-dir)) (buffer-string))
           "")
         "</body></html>")))))

For example, the section for talks that are waiting for volunteers is handled by the function emacsconf-publish-backstage-to-assign.

emacsconf-publish-backstage-to-assign
(defun emacsconf-publish-backstage-to-assign (by-status files)
  (let ((list (assoc-default "TO_ASSIGN" by-status)))
    (format "<h1>%s talk(s) to be captioned, waiting for volunteers (%d minutes)</h1><p>You can e-mail <a href=\"mailto:sacha@sachachua.com\">sacha@sachachua.com</a> to call dibs on editing the captions for one of these talks. We use OpenAI Whisper to provide auto-generated VTT that you can use as a starting point, but you can also write the captions from scratch if you like. If you're writing them from scratch, you can choose to include timing information, or we can probably figure them out afterwards with a forced alignment tool. More info: <a href=\"https://emacsconf.org/captioning/\">captioning tips</a></p><ul class=\"videos\">%s</ul>"
            (length list)
            (emacsconf-sum :video-time list)
            (mapconcat
             (lambda (f)
               (setq f (append
                        f
                        (list :extra
                              (if (plist-get f :caption-note) (concat "<div class=\"caption-note\">" (plist-get f :caption-note) "</div>") "")
                              :files
                              (emacsconf-publish-talk-files f files))))
               (format  "<li><a name=\"%s\"></a><strong><a href=\"%s\">%s</a></strong><br />%s (id:%s)<br />%s</li>"
                        (plist-get f :slug)
                        (plist-get f :absolute-url)
                        (plist-get f :title)
                        (plist-get f :speakers)
                        (plist-get f :slug)
                        (emacsconf-publish-index-card f)))
             list
             "\n"))))

Each talk has a little card that includes its video and links to files.

emacsconf-publish-index-card: Format an HTML card for TALK, linking the files in EXTENSIONS.
(defun emacsconf-publish-index-card (talk &optional extensions)
  "Format an HTML card for TALK, linking the files in EXTENSIONS."
  (let* ((file-prefix (plist-get talk :file-prefix))
         (video-file (plist-get talk :video-file))
         (video (and file-prefix
                     (emacsconf-publish-index-card-video
                      (or (plist-get talk :video-id)
                          (concat (plist-get talk :slug) "-mainVideo"))
                      video-file talk extensions))))
    ;; Add extra information to the talk
    (setq talk
          (append
           talk
           (list
            :time-info (emacsconf-surround "Duration: " (plist-get talk :video-duration) " minutes" "")
            :video-html (or (plist-get video :video) "")
            :audio-html (or (plist-get video :audio) "")
            :chapter-list (or (plist-get video :chapter-list) "")
            :resources (or (plist-get video :resources) "")
            :extra (or (plist-get talk :extra) "")
            :speaker-info (or (plist-get talk :speakers-with-pronouns) ""))))
    (emacsconf-replace-plist-in-string
     talk
     "<div class=\"vid\">${video-html}${audio-html}<div>${extra}</div>${time-info}${resources}${chapter-list}</div>")))

So it's all built on templates and a little bit of code to make the appropriate lists. You can find this code in emacsconf-publish.el.

#EmacsConf backstage: automatically updating talk status from the crontab

| emacs, emacsconf, org

Now that I've figured out how to automatically play EmacsConf talks with crontab, I want to update our approach to using TRAMP and timers to run two tracks semi-automatically.

When a talk starts playing, I would like to:

  • announce it on IRC so that the talk details split up the chat log and make it easier to read
  • publish the media files to https://media.emacsconf.org/2023 (currently password-protected while testing) so that people can view them
  • update the talk wiki page with the media files and the captions
  • update the Youtube and Toobnix videos so that they're public: this is manual for the moment, since I haven't put time into automating it yet

I have code for most of this, and we've done it successfully for the past couple of years. I just need to refamiliarize myself with how to do it and how to set it up for testing during the dry run, and modify it to work with the new crontab-based system.

Setting things up

Last year, I set up the server with the ability to act as the controller, so that it wasn't limited to my laptop. The organizers notebook says I put it in the orga@ user's account, and I probably ran it under a screen session. I've submitted a talk for this year's conference, so I can use a test video for that one. First, I need to update the Ansible configuration for publishing and editing:

ansible-playbook -i inventory.yml prod-playbook.yml --tags publish,edit

I needed to add a version attribute to the git repo checkout in the Ansible playbook, since we'd switched from master to main. I also needed to set emacs_version to 29.1 since I started using seq-keep in my Emacs Lisp functions. For testing, I set emacsconf-publishing-phase to conference.

Act on TODO state changes

org-after-todo-state-change-hook makes it easy to automatically run functions when the TODO state changes. I add this hook that runs a list of functions and passes the talk information so that I don't have to parse the talk info in each function.

emacsconf-org-after-todo-state-change: Run all the hooks in ‘emacsconf-todo-hooks’.
(defun emacsconf-org-after-todo-state-change ()
  "Run all the hooks in `emacsconf-todo-hooks'.
If an `emacsconf-todo-hooks' entry is a list, run it only for the
tracks with the ID in the cdr of that list."
  (let* ((talk (emacsconf-get-talk-info-for-subtree))
         (track (emacsconf-get-track (plist-get talk :track))))
    (mapc
     (lambda (hook-entry)
       (cond
        ((symbolp hook-entry) (funcall hook-entry talk))
        ((member (plist-get track :id) (cdr hook-entry))
         (funcall (car hook-entry) talk))))
     emacsconf-todo-hooks)))

This can be enabled and disabled with the following functions.

emacsconf-add-org-after-todo-state-change-hook: Add FUNC to ‘org-after-todo-stage-change-hook’.
(defun emacsconf-add-org-after-todo-state-change-hook ()
  "Add FUNC to `org-after-todo-stage-change-hook'."
  (interactive)
  (with-current-buffer (find-buffer-visiting emacsconf-org-file)
    (add-hook 'org-after-todo-state-change-hook #'emacsconf-org-after-todo-state-change nil t)))

emacsconf-remove-org-after-todo-state-change-hook: Remove FUNC from ‘org-after-todo-stage-change-hook’.
(defun emacsconf-remove-org-after-todo-state-change-hook ()
  "Remove FUNC from `org-after-todo-stage-change-hook'."
  (interactive)
  (with-current-buffer (find-buffer-visiting emacsconf-org-file)
    (remove-hook 'org-after-todo-state-change-hook
                 #'emacsconf-org-after-todo-state-change  t)))

Announce on IRC

This is still much the same as last year.

emacsconf-erc-announce-on-change: Announce talk.
(defun emacsconf-erc-announce-on-change (talk)
  "Announce talk."
  (let ((func
         (pcase org-state
           ("PLAYING" #'erc-cmd-NOWPLAYING)
           ("CLOSED_Q" #'erc-cmd-NOWCLOSEDQ)
           ("OPEN_Q" #'erc-cmd-NOWOPENQ)
           ("UNSTREAMED_Q" #'erc-cmd-NOWUNSTREAMEDQ)
           ("TO_ARCHIVE" #'erc-cmd-NOWDONE))))
    (when func
      (funcall func talk))))

Here's a sample command that announces that the talk is now playing.

erc-cmd-NOWPLAYING: Set the channel topics to announce TALK.
(defun erc-cmd-NOWPLAYING (talk)
  "Set the channel topics to announce TALK."
  (interactive (list (emacsconf-complete-talk-info)))
  (when (stringp talk)
    (setq talk (or (emacsconf-find-talk-info talk) (error "Could not find talk %s" talk))))
  ;; Announce it in the track's channel
  (if (emacsconf-erc-recently-announced (format "---- %s:" (plist-get talk :slug)))
      (message "Recently announced, skipping")
    (when (plist-get talk :track)
      (emacsconf-erc-with-channels (list (concat "#" (plist-get talk :channel)))
        (erc-cmd-TOPIC
         (format
          "%s: %s (%s) pad: %s Q&A: %s | %s"
          (plist-get talk :slug)
          (plist-get talk :title)
          (plist-get talk :speakers)
          (plist-get talk :pad-url)
          (plist-get talk :qa-info)
          (car (assoc-default
                (concat "#" (plist-get talk :channel))
                emacsconf-topic-templates))))
        (erc-send-message (format "---- %s: %s - %s ----"
                                  (plist-get talk :slug)
                                  (plist-get talk :title)
                                  (plist-get talk :speakers-with-pronouns)))
        (erc-send-message
         (concat "Add your notes/questions to the pad: " (plist-get talk :pad-url)))
        (cond
         ((string-match "live" (or (plist-get talk :q-and-a) ""))
          (erc-send-message (concat "Live Q&A: " (plist-get talk :bbb-redirect))))
         ((plist-get talk :irc)
          (erc-send-message (format "or discuss the talk on IRC (nick: %s)"
                                    (plist-get talk :irc)))))))
    ;; Short announcement in #emacsconf
    (emacsconf-erc-with-channels (list emacsconf-erc-hallway emacsconf-erc-org)
      (erc-send-message (format "-- %s track: %s: %s (watch: %s, pad: %s, channel: #%s)"
                                (plist-get talk :track)
                                (plist-get talk :slug)
                                (plist-get talk :title)
                                (plist-get talk :watch-url)
                                (plist-get talk :pad-url)
                                (plist-get talk :channel))))))

Because the commands change the topic, I need to op the user in all the Emacsconf channels.

erc-cmd-OPALL
(defun erc-cmd-OPALL (&optional nick)
  (emacsconf-erc-with-channels (mapcar 'car emacsconf-topic-templates)
    (if nick
        (erc-cmd-OP nick)
      (erc-cmd-OPME))))

This code is in emacsconf-erc.el.

Publish media files

We used to scramble to upload all the videos in the days or weeks after the conference, since the presentations were live. Since we switched to encouraging speakers to upload videos before the conference, we've been able to release the videos pretty much as soon as the talk starts playing. This code in emacsconf-publish.el takes care of copying the files from the backstage to the public media directory, republishing the index, and republishing the playlist. That way, people who come in late or who want to refer to the video can easily get the full video right away.

emacsconf-publish-media-files-on-change: Publish the files and update the index.
(defun emacsconf-publish-media-files-on-change (talk)
  "Publish the files and update the index."
  (interactive (list (emacsconf-complete-talk-info)))
  (let ((org-state (if (boundp 'org-state) org-state (plist-get talk :status))))
    (if (plist-get talk :public)
        ;; Copy main extension files from backstage to public
        (let ((files (directory-files emacsconf-backstage-dir nil
                                      (concat "^"
                                              (regexp-quote (plist-get talk :file-prefix))
                                              (regexp-opt emacsconf-main-extensions)))))
          (mapc (lambda (file)
                  (when (and
                         (not (file-exists-p (expand-file-name file emacsconf-public-media-directory)))
                         (or (not (string-match "--main.vtt$" file))
                             (plist-get talk :captions-edited)))
                    (copy-file (expand-file-name file emacsconf-backstage-dir)
                               (expand-file-name file emacsconf-public-media-directory) t)))
                files))
      ;; Remove files from public
      (let ((files (directory-files emacsconf-public-media-directory nil
                                    (concat "^"
                                            (regexp-quote (plist-get talk :file-prefix)
                                                          )))))
        (mapc (lambda (file)
                (delete-file (expand-file-name file emacsconf-public-media-directory)))
              files)))
    (emacsconf-publish-public-index)
    (emacsconf-publish-playlist
     (expand-file-name "index.m3u" emacsconf-public-media-directory)
     (concat emacsconf-name " " emacsconf-year)
     (emacsconf-public-talks (emacsconf-get-talk-info)))))

The :public property is automatically added by this function based on the TODO status or the time:

emacsconf-add-talk-status: Add status label and public info.
(defun emacsconf-add-talk-status (o)
  "Add status label and public info."
  (plist-put o :status-label
             (or (assoc-default (plist-get o :status)
                                emacsconf-status-types 'string= "")
                 (plist-get o :status)))
  (when (or
         (member (plist-get o :status)
                 (split-string "PLAYING CLOSED_Q OPEN_Q UNSTREAMED_Q TO_ARCHIVE TO_EXTRACT TO_FOLLOW_UP DONE"))
         (time-less-p (plist-get o :start-time)
                      (current-time)))
    (plist-put o :public t))
  o)

Update the wiki page

This function updates the schedule page and the page for the talk. It's also in emacsconf-publish.el.

emacsconf-publish-update-talk: Publish the schedule page and the page for this talk.
(defun emacsconf-publish-update-talk (talk)
  "Publish the schedule page and the page for this talk."
  (interactive (list (emacsconf-complete-talk-info)))
  (when (stringp talk) (setq talk (emacsconf-resolve-talk talk)))
  (when (functionp 'emacsconf-upcoming-insert-or-update)
    (emacsconf-upcoming-insert-or-update))
  (emacsconf-publish-with-wiki-change
    (let ((info (emacsconf-publish-prepare-for-display (emacsconf-get-talk-info))))
      (emacsconf-publish-before-page talk info)
      (emacsconf-publish-after-page talk info)
      (emacsconf-publish-schedule info))))

It uses the publishing functions I described in this post on adding talks to the wiki.

This macro commits changes when emacsconf-publish-autocommit-wiki is t, so I need to set that also.

emacsconf-publish-with-wiki-change
(defmacro emacsconf-publish-with-wiki-change (&rest body)
  (declare (indent 0) (debug t))
  `(progn
     ,@body
     (emacsconf-publish-commit-and-push-wiki-maybe
      ,emacsconf-publish-autocommit-wiki
      (and (stringp ,(car body)) ,(car body)))))

Make the videos public on YouTube and Toobnix

This is low-priority, but it might be nice to figure out. The easiest way is probably to use open the Youtube/Toobnix URLs on my computer and then use either Tampermonkey or Spookfox to set the talk to public. Someday!

Update the talk status on the server

Last year, I experimented with having the shell scripts automatically update the status of the talk from TO_STREAM to PLAYING and from PLAYING to CLOSED_Q. Since I've moved the talk-running into track-specific crontabs, now I need to sudo back to the orga user and set XDG_RUNTIME_DIR in order to use emacsclient. I can call this with sudo -u orga talk $slug $status in the roles/obs/templates/handle-session script.

Here's the Ansible template for roles/prerec/templates/talk. It uses getent to look up the user ID.

#!/bin/bash
# 
# How to use: talk slug from-status-regexp to-status
# or talk slug to-status

SLUG="$1"
FROM_STATUS="$2"
TO_STATUS="$3"
XDG_RUNTIME_DIR=/run/user/

if [ "x$TO_STATUS" == "x" ]; then
    FROM_STATUS=.
    TO_STATUS="$2"
fi
cd 
#echo "Pulling conf.org..."
#git pull
echo "Updating status..."
XDG_RUNTIME_DIR=$XDG_RUNTIME_DIR emacsclient --eval "(emacsconf-with-todo-hooks (emacsconf-update-talk-status \"$SLUG\" \"$FROM_STATUS\" \"$TO_STATUS\"))" -a emacs
#echo "Committing and pushing in the background"
#git commit -m "Update task status for $SLUG from $FROM_STATUS to $TO_STATUS" conf.org
#git push &

Testing notes

Looks like everything works fine when I run it from the crontab: the talk status is updated, the media files are published, the wiki is updated, and the talks are announced on IRC. Backup plan A is to manually control the talk status using Emacs on the server. Backup plan B is to control the talk status using Emacs on my laptop. Backup plan C is to call the individual functions instead of relying on the todo state change functions. I think it'll all work out, although I'll probably want to do another dry run at some point to make sure. Slowly getting there…