Categories: youtube

RSS - Atom - Subscribe via email

Quick notes on livestreaming to YouTube with FFmpeg on a Lenovo X230T

| video, youtube, streaming, ffmpeg, yay-emacs

[2024-01-05 Fri]: Updated scripts

Text from the sketch

Quick thoughts on livestreaming

Why:

  • work out loud
  • share tips
  • share more
  • spark conversations
  • (also get questions about things)

Doable with ffmpeg on my X230T:

  • streaming from my laptop
  • lapel mic + system audio,
  • second screen for monitoring

Ideas for next time:

  • Overall notes in Emacs with outline, org-timer timestamped notes; capture to this file
  • Elisp to start/stop the stream → find old code
  • Use the Yeti? Better sound
  • tee to a local recording
  • grab screenshot from SuperNote mirror?

Live streaming info density:

  • High: Emacs News review, package/workflow demo
  • Narrating a blog post to make it a video
  • Categorizing Emacs News, exploring packages
  • Low: Figuring things out

YouTube can do closed captions for livestreams, although accuracy is low. Videos take a while to be ready to download.

Experimenting with working out loud

I wanted to write a report on EmacsConf 2023 so that we could share it with speakers, volunteers, participants, donors, related organizations like the Free Software Foundation, and other communities. I experimented with livestreaming via YouTube while I worked on the conference highlights.

It's a little over an hour long and probably very boring, but it was nice of people to drop by and say hello.

The main parts are:

  • 0:00: reading through other conference reports for inspiration
  • 6:54: writing an overview of the talks
  • 13:10: adding quotes for specific talks
  • 25:00: writing about the overall conference
  • 32:00: squeezing in more highlights
  • 49:00: fiddling with the formatting and the export

It mostly worked out, aside from a brief moment of "uhhh, I'm looking at our private conf.org file on stream". Fortunately, the e-mail addresses that were showed were the public ones.

Technical details

Setup:

  • I set up environment variables and screen resolution:

      # From pacmd list-sources | egrep '^\s+name'
      LAPEL=alsa_input.usb-Jieli_Technology_USB_Composite_Device_433035383239312E-00.mono-fallback #
      YETI=alsa_input.usb-Blue_Microphones_Yeti_Stereo_Microphone_REV8-00.analog-stereo
      SYSTEM=alsa_output.pci-0000_00_1b.0.analog-stereo.monitor
      # MIC=$LAPEL
      # AUDIO_WEIGHTS="1 1"
      MIC=$YETI
      AUDIO_WEIGHTS="0.5 0.5"
      OFFSET=+1920,430
      SIZE=1280x720
      SCREEN=LVDS-1  # from xrandr
      xrandr --output $SCREEN --mode 1280x720
    
  • I switch to a larger size and a light theme. I also turn consult previews off to minimize the risk of leaking data through buffer previews.
    my-emacsconf-prepare-for-screenshots: Set the resolution, change to a light theme, and make the text bigger.
    (defun my-emacsconf-prepare-for-screenshots ()
      (interactive)
      (shell-command "xrandr --output LVDS-1 --mode 1280x720")
      (modus-themes-load-theme 'modus-operandi)
      (my-hl-sexp-update-overlay)
      (set-face-attribute 'default nil :height 170)
      (keycast-mode))
    

Testing:

ffmpeg -f x11grab -video_size $SIZE -i :0.0$OFFSET -y /tmp/test.png; display /tmp/test.png
ffmpeg -f pulse -i $MIC -f pulse -i $SYSTEM -filter_complex amix=inputs=2:weights=$AUDIO_WEIGHTS:duration=longest:normalize=0 -y /tmp/test.mp3; mpv /tmp/test.mp3
DATE=$(date "+%Y-%m-%d-%H-%M-%S")
ffmpeg -f x11grab -framerate 30 -video_size $SIZE -i :0.0$OFFSET -f pulse -i $MIC -f pulse -i $SYSTEM -filter_complex "amix=inputs=2:weights=$AUDIO_WEIGHTS:duration=longest:normalize=0" -c:v libx264 -preset fast -maxrate 690k -bufsize 2000k -g 60 -vf format=yuv420p -c:a aac -b:a 96k -y -flags +global_header "/home/sacha/recordings/$DATE.flv" -f flv

Streaming:

DATE=$(date "+%Y-%m-%d-%H-%M-%S")
ffmpeg -f x11grab -framerate 30 -video_size $SIZE -i :0.0$OFFSET -f pulse -i $MIC -f pulse -i $SYSTEM -filter_complex "amix=inputs=2:weights=$AUDIO_WEIGHTS:duration=longest:normalize=0[audio]" -c:v libx264 -preset fast -maxrate 690k -bufsize 2000k -g 60 -vf format=yuv420p -c:a aac -b:a 96k -y -f tee -map 0:v -map '[audio]' -flags +global_header  "/home/sacha/recordings/$DATE.flv|[f=flv]rtmp://a.rtmp.youtube.com/live2/$YOUTUBE_KEY"

To restore my previous setup:

my-emacsconf-back-to-normal: Go back to a more regular setup.
(defun my-emacsconf-back-to-normal ()
  (interactive)
  (shell-command "xrandr --output LVDS-1 --mode 1366x768")
  (modus-themes-load-theme 'modus-vivendi)
  (my-hl-sexp-update-overlay)
  (set-face-attribute 'default nil :height 115)
  (keycast-mode -1))

Ideas for next steps

I can think of a few workflow tweaks that might be fun:

  • a stream notes buffer on the right side of the screen for context information, timestamped notes to make editing/review easier (maybe using org-timer), etc. I experimented with some streaming-related code in my config, so I can dust that off and see what that's like. I also want to have an org-capture template for it so that I can add notes from anywhere.
  • a quick way to add a screenshot from my Supernote to my Org files

I think I'll try going through an informal presentation or Emacs News as my next livestream experiment, since that's probably higher information density.

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: 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

| 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.

My new Google Hangouts On Air checklist, plus upcoming Nov 29 Q&A on learning

Posted: - Modified: | process, youtube, streaming

Google Hangouts On Air is a quick, free way to have a videocast with up to 10 participants and as many passive watchers as you want, thanks to streaming through YouTube. The stream is about 20 seconds delayed and the commenting interface is still kinda raw, but as a quick way to set up and broadcast video chats, it’s hard to beat that.

I picked up a lot of great ideas from Pat Flynn’s first Q&A Hangout. He used ChatWing to set up a chat room that everyone could join, and the experience was much smoother than using CommentTracker or something like that.

Here’s my new Google Hangouts On Air workflow for the Emacs Chats series I’ve been doing. Since the Emacs crowd is fairly technical, I used IRC as my chat room, with a web interface for others who didn’t have an IRC client handy. (Naturally, I used ERC to chat on IRC from within Emacs.) Having other people around worked out really well, because I could take a break and ask other people’s questions. =)

2013-11-03 My new Google Hangouts workflow

The other new thing I tried this time around was starting the broadcast really early (like, half an hour early) and setting it to share my screen with the coming-soon information, which meant that I could post the streaming URL in lots of different places.

I’d like to expand this to doing regular Hangouts On Air Q&As or conversations. How about we use the sach.ac/live URL to point to the next Hangout On Air I’ve scheduled? As of this writing, this will be a Q&A on November 29 on learning and note-taking. We’ll probably stream it over YouTube and have a chat room for discussion/Q&A. Want to pick my brain? See you then!

(Want more one-on-one help? Book a Helpout session – there’s a nominal charge to keep slots available instead of letting no-shows book them all.)