Categories: emacsconf

View topic page - RSS - Atom - Subscribe via email

EmacsConf backstage: Makefile targets

Posted: - Modified: | emacsconf

[2024-11-16 Sat]: Removed highlight_words from whisperx call.

We like to use pre-recorded videos at EmacsConf to minimize technical risks. This also means we can caption them beforehand, stream them with open captions, and publish them as soon as the talk goes live.

Here's the process:

  1. Speakers upload their videos in whatever format they like. We use PsiTransfer to accept the uploaded files.
  2. We rename the files to have the talk title and speaker name in the filename, like emacsconf-2024-emacs30--emacs-30-highlights--philip-kaludercic--original.mov.
  3. We use FFmpeg to reencode them to WEBM so that everything is available in a free format, and we replace the --original.* part with --reencoded.webm. We copy this to --main.webm as a starting point.
  4. We extract the audio and save it to --reencoded.opus.
  5. We use ffmpeg-normalize to normalize the audio and save it to --normalized.opus.
  6. We use WhisperX to get a reasonable starting point for captions, which we save to --reencoded.vtt. I remove the underlines and the tsv and srt files.
  7. Someone edits the captions. We save edited captions as --main.vtt.
  8. --normalized.opus and --main.vtt get combined into --main.webm.

I've been slowly learning how to set up Makefile rules to automate more and more of this. Let's go through parts of the roles/prerec/templates/Makefile.

Make the reencoded webm from the original MP4, MOV, MKV, or WEBM

Here's the rule that makes a --reencoded.webm based on the original mp4, mov, mkv, or webm.

VIDEO_EXTS = mp4 mkv webm mov
source_patterns = $(foreach ext,$(VIDEO_EXTS),$(1)--original.$(ext))
emacsconf-%--reencoded.webm: SOURCES = $(call source_patterns, emacsconf-$*)
emacsconf-%--reencoded.webm:
  $(eval SOURCE := $(lastword $(sort $(wildcard $(SOURCES)))))
  @if [ -z "$(SOURCE)" ]; then \
    echo "No source file found for $@"; \
    echo "Tried: $(SOURCES)"; \
    exit 1; \
  fi
  @echo "Using source: $(SOURCE)"
  ./reencode-in-screen.sh "$(SOURCE)"

Reencoding can take a while and it's prone to me accidentally breaking it, so we stick it in a GNU screen so that I don't accidentally quit it. This is reencode-in-screen.sh:

#!/bin/bash
ORIGINAL=$1
BASE="${ORIGINAL%--original.*}"
REENCODED="${BASE}--reencoded.webm"
SLUG=$(echo "$ORIGINAL" | perl -ne '/^emacsconf-[0-9]*-(.*?)--/ && print $1')
LOCK=".lock-$SLUG"

if [ ! -f "$REENCODED" ]; then
    if [  -f "$LOCK" ]; then
        echo "$LOCK already exists, waiting for it"
    else
        touch "$LOCK"
        screen -dmS reencode-$SLUG /bin/bash -c "reencode.sh \"$ORIGINAL\" \"$REENCODED\" && thumbnail.sh \"$MAIN\" && rm \"$LOCK\""
        echo "Processing $REENCODED in reencode-$SLUG"
    fi
fi

which calls roles/prerec/templates/reencode.sh. Here's the templatized version from Ansible:

#!/usr/bin/env bash

set -euo pipefail

# Defaults
q={{ reencode_quality }}
cpu={{ reencode_cpu }}
time_limit=""
print_only=false
limit_resolution={{ res_y }}
limit_fps={{ fps }}

while getopts :q:c:t:s OPT; do
    case $OPT in
        q|+q)
            q="$OPTARG"
            ;;
        c|+c)
            cpu="$OPTARG"
            ;;
        t|+t)
            time_limit="-to $OPTARG"
            ;;
        s)
            print_only=true
            ;;
        *)
            echo "usage: `basename $0` [+-q ARG] [+-c ARG} [--] ARGS..."
            exit 2
    esac
done
shift `expr $OPTIND - 1`
OPTIND=1

input="$1"
output="${2:-$(echo $input | sed 's/--original.*/--reencoded.webm/')}"

command="$(cat<<EOF
ffmpeg -y -i "$input" $time_limit \
       -vf "scale='-1':'min($limit_resolution,ih)',
            fps='$limit_fps'" \
       -c:v libvpx-vp9 -b:v 0 -crf $q -an \
       -row-mt 1 -tile-columns 2 -tile-rows 2 -cpu-used $cpu -g 240 \
       -pass 1 -f webm -threads $cpu /dev/null &&
    ffmpeg -y -i "$input" $time_limit \
           -vf "scale='-1':'min($limit_resolution,ih)',
                fps='$limit_fps'" \
               -c:v libvpx-vp9 -b:v 0 -crf $q -c:a libopus \
               -row-mt 1 -tile-columns 2 -tile-rows 2 -cpu-used $cpu \
               -pass 2 -threads $cpu -- "$output"
EOF
)"

if [ $print_only == true ]; then
    echo "$command"
else
    eval "$command"
fi

Process the audio and captions

Processing the audio is relatively straightforward.

emacsconf-%--reencoded.opus: emacsconf-%--reencoded.webm
  ffmpeg -i "$<" -c:a copy "$@"

emacsconf-%--normalized.opus: emacsconf-%--reencoded.opus
  ffmpeg-normalize "$<" -ofmt opus -c:a libopus -o "$@"

emacsconf-%--reencoded.vtt: emacsconf-%--reencoded.opus
  whisperx --model large-v2 --align_model WAV2VEC2_ASR_LARGE_LV60K_960H --compute_type int8 --print_progress True --max_line_width 50 --segment_resolution chunk --max_line_count 1 --language en "$<"

After this, we need to manually process the --reencoded.vtt and then eventually save the edited version as --main.vtt.

Combine the video, audio, and subtitles

The next part of the Makefile creates the --main.webm from the reencoded, normalized, and edited files, or from just the --reencoded.webm if that's all that's available.

emacsconf-%--main.webm: emacsconf-%--reencoded.webm emacsconf-%--normalized.opus emacsconf-%--main.vtt
  ffmpeg -i emacsconf-$*--reencoded.webm -i emacsconf-$*--normalized.opus -i emacsconf-$*--main.vtt \
    -map 0:v -map 1:a -c:v copy -c:a copy \
    -map 2 -c:s webvtt -y \
    $@

emacsconf-%--main.webm: emacsconf-%--reencoded.webm
  cp "$<" "$@"

This works because the Makefile picks the most specific set of dependencies.

Making all the files based on the original ones that are available

Finally, we need some rules to make various things. We do this with a wildcard match for all the original files, and then we make a list without the --original.*. After that, we can just use addsuffix to add the different file endings.

PRERECS_ORIGINAL := $(wildcard emacsconf-*--original.*)
PREFIXES := $(shell for f in $(PRERECS_ORIGINAL); do echo "$${f%--original.*}"; done)
PRERECS_REENCODED := $(addsuffix --reencoded.webm, $(PREFIXES))
PRERECS_OPUS := $(addsuffix --reencoded.opus, $(PREFIXES))
PRERECS_NORMAL := $(addsuffix --normalized.opus, $(PREFIXES))
PRERECS_MAIN := $(addsuffix --main.webm, $(PREFIXES))
PRERECS_CAPTIONS := $(addsuffix --reencoded.vtt, $(PREFIXES))

all: reencoded opus normal main
reencoded: $(PRERECS_REENCODED)
opus: $(PRERECS_OPUS)
normal: $(PRERECS_NORMAL)
captions: $(PRERECS_CAPTIONS)
main: $(PRERECS_MAIN)

I sometimes do the captions on my computer, so I've left them out of the all target.

Seems to be doing all right so far. It's nice having the Makefile figure out what's changed and what needs to be updated.

View org source for this post

EmacsConf backstage: making lots of intro videos with subed-record

| emacsconf, subed, emacs

Summary (735 words): Emacs is a handy audio/video editor. subed-record can combine multiple audio files and images to create multiple output videos.

Watch on YouTube

It's nice to feel like you're saying someone's name correctly. We ask EmacsConf speakers to introduce themselves in the first few seconds of their video, but people often forget to do that, so that's okay. We started recording introductions for EmacsConf 2022 so that stream hosts don't have to worry about figuring out pronunciation while they're live. Here's how I used subed-record to turn my recordings into lots of little videos.

First, I generated the title images by using Emacs Lisp to replace text in a template SVG and then using Inkscape to convert the SVG into a PNG. Each image showed information for the previous talk as well as the upcoming talk. (emacsconf-stream-generate-in-between-pages)

emacsconf.svg.png
Figure 1: Sample title image

Then I generated the text for each talk based on the title, the speaker names, pronunciation notes, pronouns, and type of Q&A. Each introduction generally followed the pattern, "Next we have title by speakers. Details about Q&A." (emacsconf-pad-expand-intro and emacsconf-subed-intro-subtitles below)

00:00:00.000 --> 00:00:00.999
#+OUTPUT: sat-open.webm
[[file:/home/sacha/proj/emacsconf/2023/assets/in-between/sat-open.svg.png]]
Next, we have "Saturday opening remarks".

00:00:05.000 --> 00:00:04.999
#+OUTPUT: adventure.webm
[[file:/home/sacha/proj/emacsconf/2023/assets/in-between/adventure.svg.png]]
Next, we have "An Org-Mode based text adventure game for learning the basics of Emacs, inside Emacs, written in Emacs Lisp", by Chung-hong Chan. He will answer questions via Etherpad.

I copied the text into an Org note in my inbox, which Syncthing copied over to the Orgzly Revived app on my Android phone. I used Google Recorder to record the audio. I exported the m4a audio file and a rough transcript, copied them back via Syncthing, and used subed-record to edit the audio into a clean audio file without oopses.

Each intro had a set of captions that started with a NOTE comment. The NOTE comment specified the following:

  • #+AUDIO:: the audio source to use for the timestamped captions that follow
  • [[file:...]]: the title image I generated for each talk. When subed-record-compile-video sees a comment with a link to an image, video, or animated GIF, it takes that visual and uses it for the span of time until the next visual.
  • #+OUTPUT: the file to create.
NOTE #+OUTPUT: hyperdrive.webm
[[file:/home/sacha/proj/emacsconf/2023/assets/in-between/hyperdrive.svg.png]]
#+AUDIO: intros-2023-11-21-cleaned.opus

00:00:15.680 --> 00:00:17.599
Next, we have "hyperdrive.el:

00:00:17.600 --> 00:00:21.879
Peer-to-peer filesystem in Emacs", by Joseph Turner

00:00:21.880 --> 00:00:25.279
and Protesilaos Stavrou (also known as Prot).

00:00:25.280 --> 00:00:27.979
Joseph will answer questions via BigBlueButton,

00:00:27.980 --> 00:00:31.080
and Prot might be able to join depending on the weather.

00:00:31.081 --> 00:00:33.439
You can join using the URL from the talk page

00:00:33.440 --> 00:00:36.320
or ask questions through Etherpad or IRC.

NOTE
#+OUTPUT: steno.webm
[[file:/home/sacha/proj/emacsconf/2023/assets/in-between/steno.svg.png]]
#+AUDIO: intros-2023-11-19-cleaned.opus

00:03:23.260 --> 00:03:25.480
Next, we have "Programming with steno",

00:03:25.481 --> 00:03:27.700
by Daniel Alejandro Tapia.

NOTE
#+AUDIO: intro-2023-11-29-cleaned.opus

00:00:13.620 --> 00:00:16.580
You can ask your questions via Etherpad and IRC.

00:00:16.581 --> 00:00:18.079
We'll send them to the speaker

00:00:18.080 --> 00:00:19.919
and post the answers in the talk page

00:00:19.920 --> 00:00:21.320
after the conference.

I could then call subed-record-compile-video to create the videos for all the intros, or mark a region with C-SPC and then subed-record-compile-video only the intros inside that region.

Sample intro

Using Emacs to edit the audio and compile videos worked out really well because it made it easy to change things.

  • Changing pronunciation or titles: For EmacsConf 2023, I got the recordings sorted out in time for the speakers to correct my pronunciation if they wanted to. Some speakers also changed their talk titles midway. If I wanted to redo an intro, I just had to rerecord that part, run it through my subed-record audio cleaning process, add an #+AUDIO: comment specifying which file I want to take the audio from, paste it into my main intros.vtt, and recompile the video.
  • Cancelling talks: One of the talks got cancelled, so I needed to update the images for the talk before it and the talk after it. I regenerated the title images and recompiled the videos. I didn't even need to figure out which talk needed to be updated - it was easy enough to just recompile all of them.
  • Changing type of Q&A: For example, some speakers needed to switch from answering questions live to answering them after the conference. I could just delete the old instructions, paste in the instructions from elsewhere in my intros.vtt (making sure to set #+AUDIO to the file if it came from a different take), and recompile the video.

And of course, all the videos were captioned. Bonus!

So that's how using Emacs to edit and compile simple videos saved me a lot of time. I don't know how I'd handle this otherwise. 47 video projects that might all need to be updated if, say, I changed the template? Yikes. Much better to work with text. Here are the technical details.

Generating the title images

I used Inkscape to add IDs to our template SVG so that I could edit them with Emacs Lisp. From emacsconf-stream.el:

emacsconf-stream-generate-in-between-pages: Generate the title images.
(defun emacsconf-stream-generate-in-between-pages (&optional info)
  "Generate the title images."
  (interactive)
  (setq info (or emacsconf-schedule-draft (emacsconf-publish-prepare-for-display (emacsconf-filter-talks (or info (emacsconf-get-talk-info))))))
  (let* ((by-track (seq-group-by (lambda (o) (plist-get o :track)) info))
         (dir (expand-file-name "in-between" emacsconf-stream-asset-dir))
         (template (expand-file-name "template.svg" dir)))
    (unless (file-directory-p dir)
      (make-directory dir t))
    (mapc (lambda (track)
            (let (prev)
              (mapc (lambda (talk)
                      (let ((dom (xml-parse-file template)))
                        (mapc (lambda (entry)
                                (let ((prefix (car entry)))
                                  (emacsconf-stream-svg-set-text dom (concat prefix "title")
                                                 (plist-get (cdr entry) :title))
                                  (emacsconf-stream-svg-set-text dom (concat prefix "speakers")
                                                 (plist-get (cdr entry) :speakers))
                                  (emacsconf-stream-svg-set-text dom (concat prefix "url")
                                                 (and (cdr entry) (concat emacsconf-base-url (plist-get (cdr entry) :url))))
                                  (emacsconf-stream-svg-set-text
                                   dom
                                   (concat prefix "qa")
                                   (pcase (plist-get (cdr entry) :q-and-a)
                                     ((rx "live") "Live Q&A after talk")
                                     ((rx "pad") "Etherpad")
                                     ((rx "IRC") "IRC Q&A after talk")
                                     (_ "")))))
                              (list (cons "previous-" prev)
                                    (cons "current-" talk)))
                        (with-temp-file (expand-file-name (concat (plist-get talk :slug) ".svg") dir)
                          (dom-print dom))
                        (shell-command
                         (concat "inkscape --export-type=png -w 1280 -h 720 --export-background-opacity=0 "
                                 (shell-quote-argument (expand-file-name (concat (plist-get talk :slug) ".svg")
                                                                         dir)))))
                      (setq prev talk))
                    (emacsconf-filter-talks (cdr track)))))
          by-track)))

emacsconf-stream-svg-set-text: Update DOM to set the tspan in the element with ID to TEXT.
(defun emacsconf-stream-svg-set-text (dom id text)
  "Update DOM to set the tspan in the element with ID to TEXT.
If the element doesn't have a tspan child, use the element itself."
  (if (or (null text) (string= text ""))
      (let ((node (dom-by-id dom id)))
        (when node
          (dom-set-attribute node 'style "visibility: hidden")
          (dom-set-attribute (dom-child-by-tag node 'tspan) 'style "fill: none; stroke: none")))
    (setq text (svg--encode-text text))
    (let ((node (or (dom-child-by-tag
                     (car (dom-by-id dom id))
                     'tspan)
                    (dom-by-id dom id))))
      (cond
       ((null node)
        (error "Could not find node %s" id))                      ; skip
       ((= (length node) 2)
        (nconc node (list text)))
       (t (setf (elt node 2) text))))))

Generating the script

From emacsconf-pad.el:

emacsconf-pad-expand-intro: Make an intro for TALK.
(defun emacsconf-pad-expand-intro (talk)
  "Make an intro for TALK."
  (cond
   ((null (plist-get talk :speakers))
    (format "Next, we have \"%s\"." (plist-get talk :title)))
   ((plist-get talk :intro-note)
    (plist-get talk :intro-note))
   (t
    (let ((pronoun (pcase (plist-get talk :pronouns)
                     ((rx "she") "She")
                     ((rx "\"ou\"" "Ou"))
                     ((or 'nil "nil" (rx string-start "he") (rx "him")) "He")
                     ((rx "they") "They")
                     (_ (or (plist-get talk :pronouns) "")))))
      (format "Next, we have \"%s\", by %s%s.%s"
              (plist-get talk :title)
              (replace-regexp-in-string ", \\([^,]+\\)$"
                                        ", and \\1"
                                        (plist-get talk :speakers))
              (emacsconf-surround " (" (plist-get talk :pronunciation) ")" "")
              (pcase (plist-get talk :q-and-a)
                ((or 'nil "") "")
                ((rx "after") " You can ask questions via Etherpad and IRC. We'll send them to the speaker, and we'll post the answers on the talk page afterwards.")
                ((rx "live")
                 (format " %s will answer questions via BigBlueButton. You can join using the URL from the talk page or ask questions through Etherpad or IRC."
                         pronoun
                         ))
                ((rx "pad")
                 (format " %s will answer questions via Etherpad."
                         pronoun
                         ))
                ((rx "IRC")
                 (format " %s will answer questions via IRC in the #%s channel."
                         pronoun
                         (plist-get talk :channel)))))))))

And from emacsconf-subed.el:

emacsconf-subed-intro-subtitles: Create the introduction as subtitles.
(defun emacsconf-subed-intro-subtitles ()
  "Create the introduction as subtitles."
  (interactive)
  (subed-auto-insert)
  (let ((emacsconf-publishing-phase 'conference))
    (mapc
     (lambda (sub) (apply #'subed-append-subtitle nil (cdr sub)))
     (seq-map-indexed
      (lambda (talk i)
        (list
         nil
         (* i 5000)
         (1- (* i 5000))
         (format "#+OUTPUT: %s.webm\n[[file:%s]]\n%s"
                 (plist-get talk :slug)
                 (expand-file-name
                  (concat (plist-get talk :slug) ".svg.png")
                  (expand-file-name "in-between" emacsconf-stream-asset-dir))
                 (emacsconf-pad-expand-intro talk))))
      (emacsconf-publish-prepare-for-display (emacsconf-get-talk-info))))))

View org source for this post

EmacsConf backstage: Trimming the BigBlueButton recordings based on YouTube duration

| emacsconf, emacs, youtube, video

I wanted to get the Q&A sessions up quickly after the conference, so I uploaded them to YouTube and added them to the EmacsConf 2023 playlist. I used YouTube's video editor to roughly guess where to trim them based on the waveforms. I needed to actually trim the source videos, though, so that our copies would be up to date and I could use those for the Toobnix uploads.

My first task was to figure out which videos needed to be trimmed to match the YouTube edits. First, I retrieved the video details using the API and the code that I added to emacsconf-extract.el.

(setq emacsconf-extract-youtube-api-video-details (emacsconf-extract-youtube-get-video-details emacsconf-extract-youtube-api-playlist-items))

Then I made a table comparing the file duration with the YouTube duration, showing rows only if the difference was more than 3 minutes.

(append
 '(("type" "slug" "file duration" "youtube duration" "diff"))
 (let ((threshold-secs (* 3 60))) ; don't sweat small differences
   (seq-mapcat
    (lambda (talk)
      (seq-keep
       (lambda (row)
         (when (plist-get talk (cadr row))
           (let* ((video (emacsconf-extract-youtube-find-url-video-in-list
                          (plist-get talk (cadr row))
                          emacsconf-extract-youtube-api-video-details))
                  (video-duration (if (and video (emacsconf-extract-youtube-duration-msecs video))
                                      (/ (emacsconf-extract-youtube-duration-msecs video) 1000.0)))
                  (file-duration (ceiling
                                  (/ (compile-media-get-file-duration-ms (emacsconf-talk-file talk (format "--%s.webm" (car row))))
                                     1000.0))))
             (when (and video-duration (> (abs (- file-duration video-duration)) threshold-secs))
               (list (car row)
                     (plist-get talk :slug)
                     (and file-duration (format-seconds "%h:%z%.2m:%.2s" file-duration))
                     (and video-duration (format-seconds "%h:%z%.2m:%.2s" video-duration))
                     (emacsconf-format-seconds
                      (abs (- file-duration video-duration))))))))
       '(("main" :youtube-url)
         ("answers" :qa-youtube-url))))
    (emacsconf-publish-prepare-for-display (emacsconf-get-talk-info)))))

Then I got the commands to trim the videos.

 (mapconcat (lambda (row)
              (let ((talk (emacsconf-resolve-talk (elt row 1))))
                (format "ffmpeg -y -i %s--%s.webm -t %s -c copy %s--%s--trimmed.webm"
                        (plist-get talk :file-prefix)
                        (car row)
                        (concat (elt row 3) ".000")
                        (plist-get talk :file-prefix)
                        (car row))))
            (cdr to-trim)
            "\n"))

After quickly checking the results, I copied them over to the original videos, updated the video data in my conf.org, and republished the info pages in the wiki.

The time I spent on figuring out how to talk to the YouTube API feels like it's paying off.

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.