;;; emacsconf-extract.el --- BigBlueButton -*- lexical-binding: t; -*- ;; Copyright (C) 2022 Sacha Chua ;; Author: Sacha Chua ;; This program is free software; you can redistribute it and/or modify ;; it under the terms of the GNU General Public License as published by ;; the Free Software Foundation, either version 3 of the License, or ;; (at your option) any later version. ;; This program is distributed in the hope that it will be useful, ;; but WITHOUT ANY WARRANTY; without even the implied warranty of ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ;; GNU General Public License for more details. ;; You should have received a copy of the GNU General Public License ;; along with this program. If not, see . ;;; Commentary: ;; - set BBB_REC to the published presentation URL. If keeping the presentation recording private, don't set BBB_REC; set BBB_MEETING_ID to just the meeting ID. ;; - Use the command from emacsconf-extract-raw-recordings-download-command to download raw data. ;;; Code: (defun emacsconf-extract-chat (filename) (when (file-exists-p filename) (message "%s" filename) (mapcar (lambda (node) (when (string= (dom-attr node 'target) "chat") (let ((message (replace-regexp-in-string "\\[\\([^<]+\\)?\\](\"\\([^<]+\\)\")" "<\\2>" (condition-case nil (html-to-markdown-string (dom-attr node 'message)) (error (replace-regexp-in-string "\\(.+?\\)" "<\\2>" (dom-attr node 'message))))))) (list (string-to-number (dom-attr node 'in)) (dom-attr node 'name) message)))) (dom-by-tag (xml-parse-file filename) 'chattimeline)))) ;; (emacsconf-extract-extract-chat (expand-file-name "bbb-playbacks/haskell/slides_new.xml" emacsconf-cache-dir)) (defvar emacsconf-extract-bbb-chat-use-wall-clock-time nil "Non-nil means use wall clock time for logs.") (defun emacsconf-extract-chats () (interactive) (mapc (lambda (o) (let* ((playback-dir (expand-file-name (plist-get o :slug) (expand-file-name "bbb-playbacks" emacsconf-cache-dir))) (chat (emacsconf-extract-extract-chat (expand-file-name "slides_new.xml" playback-dir))) metadata) (when chat (setq metadata (xml-parse-file (expand-file-name "metadata.xml" playback-dir))) (let ((recording-start (/ (string-to-number (dom-text (dom-by-tag metadata 'start_time))) 1000))) (with-temp-file (expand-file-name (concat (plist-get o :file-prefix) "--extract.txt") emacsconf-cache-dir) (insert (mapconcat (lambda (line) (format "`%s` _%s_ %s \n" (if emacsconf-extract-bbb-chat-use-wall-clock-time (format-time-string "%H:%M:%S" (seconds-to-time (+ recording-start (elt line 0)))) (format-seconds "%h:%.2m:%.2s" (elt line 0))) (elt line 1) (elt line 2))) chat ""))))))) (emacsconf-prepare-for-display (emacsconf-get-talk-info)))) (defun emacsconf-extract-bbb-copy-files (&optional info) (interactive) (mapc (lambda (o) (let ((playback-dir (expand-file-name (plist-get o :slug) (expand-file-name "bbb-playbacks" emacsconf-cache-dir)))) (mapc (lambda (file) (when (and (file-exists-p (expand-file-name file playback-dir)) (not (file-exists-p (expand-file-name (concat (plist-get o :file-prefix) "--bbb-" file) emacsconf-cache-dir)))) (copy-file (expand-file-name file playback-dir) (expand-file-name (concat (plist-get o :file-prefix) "--bbb-" file) emacsconf-cache-dir) t))) '("webcams.webm" "metadata.xml" "deskshare.webm" "deskshare.xml" "slides_new.xml" "webcams.opus")))) (or info (emacsconf-prepare-for-display (emacsconf-get-talk-info))))) (defvar emacsconf-extract-dump-dir "/ssh:orga@res.emacsconf.org#46668:~/current/live0-streams/") (defun emacsconf-extract-dump-time-from-filename (f) (when (string-match "\\([0-9][0-9][0-9][0-9]\\)-\\([0-9][0-9]\\)-\\([0-9][0-9]\\)_\\([0-9][0-9]\\)-\\([0-9][0-9]\\)-\\([0-9][0-9]\\)" f) (encode-time (append (mapcar (lambda (i) (string-to-number (match-string i f))) (number-sequence 6 1 -1)) (list nil nil "UTC"))))) (defun emacsconf-extract-dump-filename (directory input-prefix start-time) (seq-find (lambda (f) (time-less-p (emacsconf-extract-dump-time-from-filename f) start-time)) (nreverse (sort (directory-files (or directory emacsconf-extract-dump-dir) nil (concat emacsconf-id "-" emacsconf-year "-" input-prefix ".*\\.webm$")) 'string<)))) ;; (emacsconf-extract-dump-filename emacsconf-extract-dump-dir "dev" (emacsconf-extract-time-or-offset-to-time "2022-12-04 11:30")) ;; emacsconf-2021-main_2021-11-20_15-31-16.webm hmm, this might be GMT (defun emacsconf-extract-dump-ffmpeg-command (input-file start-time end-time output-file &optional compress-command) (when (stringp start-time) (setq start-time (emacsconf-extract-time-or-offset-to-time start-time))) (when (stringp end-time) (setq end-time (emacsconf-extract-time-or-offset-to-time end-time))) (let* ((target-file-start (emacsconf-extract-dump-time-from-filename input-file)) (dump-args (emacsconf-extract-dump-ffmpeg-seek-and-filename input-file (- (time-to-seconds start-time) (time-to-seconds target-file-start)) (- (time-to-seconds end-time) (time-to-seconds target-file-start))))) (if compress-command (format "ffmpeg -y %s -c copy %s; %s %s &" dump-args output-file compress-command output-file) (format "ffmpeg -y %s -c copy %s" dump-args output-file)))) ;; (emacsconf-extract-dump-ffmpeg-command (emacsconf-extract-dump-filename emacsconf-extract-dump-dir "dev" (emacsconf-extract-time-or-offset-to-time "2022-12-04T11:30:00")) "2022-12-04T11:30:00" "2022-12-04T13:00:00" "rms.webm") ;; output-prefix ;; (format-time-string "%Y-%m-%d_%H-%M-%S" start-time t) (defun emacsconf-extract-dump-get-command (input-prefix start-time end-time filename) (interactive) (setq start-time (emacsconf-extract-time-or-offset-to-time start-time)) (setq end-time (emacsconf-extract-time-or-offset-to-time end-time)) (format "ssh conf -- %s; scp conf:~/emacsconf-2021-stream-dumps/%s %s" (shell-quote-argument (format "cd %s; sudo %s" "~/emacsconf-2021-stream-dumps/" (emacsconf-extract-dump-ffmpeg-command (emacsconf-extract-dump-filename emacsconf-extract-dump-dir input-prefix start-time) start-time end-time (concat "output/" filename)))) (concat "output/" filename) filename)) ;; todo timezones (defun emacsconf-extract-time-or-offset-to-time (input) (cond ((numberp input) (seconds-to-time (+ (time-to-seconds (current-time)) (* input 60)))) ((listp input) input) ((stringp input) (date-to-time (if (string-match "[-Z+]" input) input (concat input emacsconf-timezone-offset)))) ((string-match " " input) (org-read-date t t input)) (t (seconds-to-time (+ (time-to-seconds (current-time)) (* (string-to-number input) 60)))))) (defun emacsconf-extract-dump-ffmpeg-seek-and-filename (filename start-seconds to-seconds) "Return seek and input file argument." (if (> start-seconds 30) (format "-ss %f -i %s -ss %f -to %f" (- start-seconds 30) filename 30 (- to-seconds start-seconds -30)) (format "-i %s -ss %f -to %f" filename start-seconds to-seconds))) (defun emacsconf-extract-dump-get (track start-time end-time output-prefix) (interactive (list (emacsconf-complete-track) (read-string "Start: ") (read-string "End: ") (read-string "Output prefix: "))) (let ((result (emacsconf-extract-dump-get-command (concat emacsconf-id "-" emacsconf-year "-" (plist-get track :id)) (emacsconf-extract-time-or-offset-to-time start-time) (emacsconf-extract-time-or-offset-to-time end-time) (concat output-prefix (format-time-string "%Y-%m-%d_%H-%M-%S" (emacsconf-extract-time-or-offset-to-time start-time) t) ".webm")))) (when (called-interactively-p 'any) (kill-new result)) result)) (defun emacsconf-extract-dump-refine (filename starting-ts ending-ts) (interactive (list (read-file-name "Input: " nil (conf-latest-file ".") t) (read-string "Start timestamp: ") (read-string "End timestamp: "))) (let ((result (format "ffmpeg -y %s -c copy %s" () starting-ts filename starting-ts ending-ts (expand-file-name (concat "trimmed-" (file-name-nondirectory filename)) (file-name-directory filename))))) (when (called-interactively-p 'any) (kill-new result)) result)) (defvar emacsconf-extract-qa-caption-length 50) (defun emacsconf-extract-qa-from-assemblyai-sentences (file) (let ((data (with-temp-buffer (insert-file-contents file) (json-parse-buffer))) last-speaker) (subed-create-file (concat (file-name-sans-extension file) ".vtt") (mapcar (lambda (sent) (let* ((words (mapconcat (lambda (w) (propertize (gethash "text" w) 'start (gethash "start" w) 'end (gethash "end" w) 'confidence (gethash "confidence" w))) (get-hash "words" sent) " ")) (reflowed (emacsconf-split-text-based-on-heuristics words emacsconf-extract-qa-caption-length))) (seq-map-indexed (lambda (line index) (list ) ) ) (list nil (gethash "start" sent) (gethash "end" sent) (if (string= (or last-speaker "") (gethash "speaker" sent)) words (format "[Speaker %s]: %s" (gethash "speaker" sent) (gethash "text" sent))) (if (string= (or last-speaker "") (gethash "speaker" sent)) nil (setq last-speaker (gethash "speaker" sent)) (emacsconf-surround "NOTE Speaker " (gethash "speaker" sent) "\n\n" nil))))) (gethash "sentences" data))))) ;; (emacsconf-extract-qa-from-assemblyai-sentences "~/proj/emacsconf/rms/sentences") ;;;###autoload (defun emacsconf-extract-copy-pad-to-wiki () "Copy the notes and questions from the current file to the wiki page for this talk." (interactive) (let ((slug (emacsconf-get-slug-from-string (file-name-base (buffer-file-name)))) (delimiter "\\\\-\\\\-\\\\-\\\\-\\\\-") notes questions) (goto-char (point-min)) (when (re-search-forward "Notes, discussions, links, feedback:" nil t) (forward-line 1) (setq notes (string-trim (buffer-substring (point) (if (re-search-forward delimiter nil t) (match-beginning 0) (point-max)))))) (when (re-search-forward "Questions and answers go here:" nil t) (forward-line 1) (setq questions (string-trim (buffer-substring (point) (if (re-search-forward delimiter nil t) (match-beginning 0) (point-max)))))) (find-file (expand-file-name (concat slug ".md") (expand-file-name "talks" (expand-file-name emacsconf-year emacsconf-directory)))) (goto-char (point-min)) (if (re-search-forward "^# Discussion\n\n" nil t) nil (re-search-forward "-after)" nil t) (forward-line -1) (insert "# Discussion\n\n")) (save-excursion (unless (string= (or questions "") "") (insert "## Questions and answers\n\n" questions "\n\n")) (unless (string= (or notes "") "") (insert "## Notes\n\n" notes "\n\n"))))) (defun emacsconf-extract-question-headings (slug) (with-temp-buffer (insert-file-contents (expand-file-name (concat slug ".md") (expand-file-name "talks" (expand-file-name emacsconf-year emacsconf-directory)))) (goto-char (point-min)) (let (results) (while (re-search-forward "^-[ \t]+Q:[  ]*" nil t) (setq results (cons (string-trim (replace-regexp-in-string "[\n \t ]+" " " (replace-regexp-in-string "\\\\" "" (buffer-substring (point) (and (re-search-forward "^[ \t]+-\\|^[ \t]+*$" nil t) (match-beginning 0)))))) results))) (nreverse results)))) ;; (emacsconf-extract-question-headings "asmblox") (defun emacsconf-extract-insert-note-with-question-heading (question) (interactive (list (completing-read "Question: " (emacsconf-extract-question-headings (emacsconf-get-slug-from-string (file-name-base (buffer-file-name))))))) (insert "NOTE " question "\n\n")) (defun emacsconf-extract-wget-bbb (o) (when (plist-get o :bbb-playback) (let ((meeting-id (when (string-match "meetingId=\\(.+\\)" (plist-get o :bbb-playback)) (match-string 1 (plist-get o :bbb-playback))))) (concat "mkdir " (plist-get o :slug) "\n" "cd " (plist-get o :slug) "\n" (mapconcat (lambda (file) (concat "wget https://bbb.emacsverse.org/presentation/" meeting-id "/" file "\n")) '("video/webcams.webm" "metadata.xml" "deskshare/deskshare.webm" "panzooms.xml" "cursor.xml" "deskshare.xml" "captions.json" "presentation_text.json" "slides_new.xml") "") "cd ..\n")))) (defun emacsconf-extract-bbb-events-xml (o) "Copy the events.xml from the raw BBB directory copied from bbb@bbb.emacsverse.org." (if (plist-get o :bbb-playback) (let ((meeting-id (when (string-match "meetingId=\\(.+\\)" (plist-get o :bbb-playback)) (match-string 1 (plist-get o :bbb-playback))))) (format "scp ~/current/bbb-raw/%s/events.xml orga@media.emacsconf.org:~/backstage/%s--bbb-events.xml\n" meeting-id (plist-get o :file-prefix))) "")) (defun emacsconf-extract-bbb-voice-events (file) "Return a list of voice events. (:name participant :start-clock start-time :start-ms ... :stop-clock stop-time :stop-ms)." (let ((dom (xml-parse-file file)) start-recording stop-recording start-ms stop-ms participants results) (setq start-recording (date-to-time (dom-text (dom-by-tag (dom-elements dom 'eventname "StartRecordingEvent") 'date)))) (setq stop-recording (date-to-time (dom-text (dom-by-tag (dom-elements dom 'eventname "StopRecordingEvent") 'date)))) (setq start-ms (* 1000 (time-to-seconds start-recording)) stop-ms (* 1000 (time-to-seconds stop-recording))) ;; get the participant names and put them in an alist (setq participants (mapcar (lambda (o) (list (dom-text (dom-by-tag o 'userId)) :name (dom-text (dom-by-tag o 'name)))) (seq-filter (lambda (node) (string= (dom-attr node 'eventname) "ParticipantJoinEvent")) (dom-by-tag dom 'event)))) ;; get the voice events (mapc (lambda (o) (let ((participant (assoc-default (dom-text (dom-by-tag o 'participant)) participants)) (time (date-to-time (dom-text (dom-by-tag o 'date)))) o-start o-stop) (if (string= (dom-text (dom-by-tag o 'talking)) "true") ;; start talking (plist-put participant ;; although maybe timestampUTC will be useful somehow :start time) ;; clamp it to start-recording and stop-recording (when (and (time-less-p (plist-get participant :start) stop-recording) (time-less-p start-recording time)) (setq o-start (- (max (* 1000 (time-to-seconds (plist-get participant :start))) start-ms) start-ms) o-stop (- (min (* 1000 (time-to-seconds time)) stop-ms) start-ms)) (setq results (cons (list :name (plist-get participant :name) :start-ms o-start :stop-ms o-stop :start-clock (plist-get participant :start) :stop-clock time :duration-ms (- o-stop o-start)) results)))))) (seq-filter (lambda (node) (string= (dom-attr node 'eventname) "ParticipantTalkingEvent")) (dom-by-tag dom 'event))) (nreverse results))) ;; (emacsconf-extract-bbb-voice-events "~/proj/emacsconf/cache/emacsconf-2022-sqlite--using-sqlite-as-a-data-source-a-framework-and-an-example--andrew-hyatt--bbb-events.xml") ;; Okay, now that we have voice events, what can we do with them? ;; We can insert notes into the VTT for now to try to guess the speaker, when the speaker changes ;; The audio is not split up by speaker, so the transcript is also not very split up ;; Some speech-to-text systems can do speaker diarization, which also tries to identify speakers ;; huh, is the StartRecordingEvent timestamp reliable? Am I misreading it? (defvar emacsconf-extract-irc-speaker-nick nil "*Nick for the speaker.") (defun emacsconf-extract-selected-irc () "Copy the lines that start with -." (interactive) (let ((results "")) (save-excursion (goto-char (point-min)) (while (re-search-forward "^\\( *- \\([QA]: \\)?\\)\\[[0-9:]+\\] <.*?> \\(.*\n\\)" nil t) (setq results (concat results (match-string 1) (match-string 3))) (replace-match "" nil t nil 1)) (kill-new results)))) (defun emacsconf-extract-irc-backward-by-nick () (interactive) (goto-char (line-beginning-position)) (when (looking-at "\\[[0-9:]+\\] <\\(.*?\\)> \\([^ :]+?\\)?[ :]\\(.+\\)$") (save-excursion (let ((nick (match-string 2))) (while (and (re-search-backward (concat "\\[[0-9:]+\\] <" (regexp-quote nick) ">") nil t) (y-or-n-p "Continue? ")) ;; keep going backwards ))))) (defun emacsconf-extract-irc-copy-line-to-other-window-as-list-item (&optional prefix indent) (interactive) (goto-char (line-beginning-position)) (when (looking-at " *\\[[0-9:]+\\] <\\(.*?\\)> \\([^ ]+?:\\)?\\(.+\\)$") (let ((line (string-trim (match-string 3))) (prefix (or prefix (and (string= (or emacsconf-extract-irc-speaker-nick "") (match-string 1)) "A: ") ""))) (setq line (concat (if (or (string= prefix "A: ") indent) " " "") "- " prefix line "\n")) (other-window 1) (insert line) (other-window 1) (forward-line 1)))) (defun emacsconf-extract-irc-copy-line-to-other-window-as-question () (interactive) (emacsconf-extract-irc-copy-line-to-other-window-as-list-item "Q: ")) (defvar emacsconf-extract-irc-map (make-sparse-keymap)) (defalias 'emacsconf-extract-irc-other-window #'other-window) (defalias 'emacsconf-extract-irc-next-line #'next-line) (defalias 'emacsconf-extract-irc-previous-line #'previous-line) (defun emacsconf-extract-irc-open-talk-in-other-window (talk) (interactive (list (emacsconf-complete-talk-info))) (other-window 1) (emacsconf-edit-wiki-page talk)) (require 'hydra) (defhydra emacsconf-extract-irc () "Make it easy to extract lines from IRC" ("c" emacsconf-extract-irc-copy-line-to-other-window-as-list-item "copy") ("q" (emacsconf-extract-irc-copy-line-to-other-window-as-list-item "Q: ") "question") ("o" other-window "other") ("t" emacsconf-extract-irc-open-talk-in-other-window "talk") ("n" next-line "next") ("p" previous-line "previous") ("N" move-line-down "move down") ("P" move-line-up "move up") ("" (progn (goto-char (line-beginning-position)) (insert " ")) "indent") ("" (progn (goto-char (line-beginning-position)) (delete-char 2)) "dedent") ("" scroll-down-command) ("" scroll-up-command) ("a" (emacsconf-extract-irc-copy-line-to-other-window-as-list-item "A: ") "answer") ("l" (save-window-excursion (other-window 1) (consult-line)) "check line") ("r" (when (string-match )) (re-search-backward nil t)) (" " pop-to-mark-command) ) (defun emacsconf-extract-irc-anonymize-log (beg end speakers) (interactive "r\nMNick(s): ") (when (stringp speakers) (setq speakers (split-string speakers))) (let ((text (buffer-substring beg end)) nicks) (with-temp-buffer (insert text) (goto-char (point-min)) ;; make a list of nicks (while (re-search-forward "^\\[[0-9:]+\\] <\\(.*?\\)>" nil t) (unless (member (match-string 1) speakers) (add-to-list 'nicks (match-string 1)))) (goto-char (point-min)) (while (re-search-forward "^\\[[0-9:]+\\] <\\(.*?\\)> \\(.+\\)" nil t) (replace-match (if (member (match-string 1) speakers) (concat " - A: " (match-string 2)) (format "- {{%d}} %s" (seq-position nicks (match-string 1)) (propertize (match-string 2) 'nick (match-string 1)))))) (goto-char (point-min)) (perform-replace (regexp-opt nicks) (lambda ())) (setq text (buffer-string)) (other-window 1) (insert text)))) (defun emacsconf-private-qa (&optional info) (seq-remove (lambda (o) (or (null (emacsconf-talk-file o "--bbb-webcams.webm")) (plist-get o :qa-public))) (or info (emacsconf-get-talk-info)))) ;; sqlite detached localizing (defun emacsconf-extract-review-qa (talk) (interactive (list (emacsconf-complete-talk-info (emacsconf-private-qa)))) (find-file (emacsconf-talk-file talk "--bbb-webcams.vtt"))) (defun emacsconf-extract-publish-qa (talk &optional time) (interactive (list (emacsconf-complete-talk-info (unless current-prefix-arg (emacsconf-private-qa))) (when current-prefix-arg (read-string "Time: ")))) (when (stringp talk) (setq talk (emacsconf-resolve-talk talk))) (let ((buff (get-buffer-create "*ffmpeg*")) (large-file-warning-threshold nil)) (cond ((emacsconf-talk-file talk "--bbb-deskshare.webm") (apply 'call-process "ffmpeg" nil buff nil (append (list "-y" "-i" (expand-file-name (concat (plist-get talk :file-prefix) "--bbb-deskshare.webm") emacsconf-cache-dir)) (when time (list "-to" time)) (list "-i" (expand-file-name (concat (plist-get talk :file-prefix) "--bbb-webcams.opus") emacsconf-cache-dir)) (when time (list "-to" time)) (list "-c" "copy" (expand-file-name (concat (plist-get talk :file-prefix) "--answers.webm") emacsconf-cache-dir))))) (time (apply 'call-process "ffmpeg" nil buff nil (append (list "-y" "-i" (expand-file-name (concat (plist-get talk :file-prefix) "--bbb-webcams.webm") emacsconf-cache-dir)) (when time (list "-to" time)) (list "-c" "copy" (expand-file-name (concat (plist-get talk :file-prefix) "--answers.webm") emacsconf-cache-dir))))) (t (copy-file (expand-file-name (concat (plist-get talk :file-prefix) "--bbb-webcams.webm") emacsconf-cache-dir) (expand-file-name (concat (plist-get talk :file-prefix) "--answers.webm") emacsconf-cache-dir) t))) (call-process "ffmpeg" nil buff nil "-y" "-i" (emacsconf-talk-file talk "--answers.webm") "-c" "copy" (emacsconf-talk-file talk "--answers.opus" t)) (dolist (suffix '("opus" "webm")) (copy-file (expand-file-name (concat (plist-get talk :file-prefix) "--answers." suffix) emacsconf-cache-dir) (expand-file-name (concat (plist-get talk :file-prefix) "--answers." suffix) emacsconf-backstage-dir) t) (copy-file (expand-file-name (concat (plist-get talk :file-prefix) "--answers." suffix) emacsconf-backstage-dir) (expand-file-name (concat (plist-get talk :file-prefix) "--answers." suffix) emacsconf-public-media-directory) t)) (save-window-excursion (emacsconf-go-to-talk talk) (org-entry-put (point) "QA_PUBLIC" "t") (unless (string-match "Q&A posted publicly." (or (org-entry-get (point) "QA_NOTE") "")) (org-entry-put (point) "QA_NOTE" (concat "Q&A posted publicly." (emacsconf-surround " " (org-entry-get (point) "QA_NOTE") "" ""))))))) ;; (emacsconf-extract-publish-qa "workflows" "13:56.000") (emacsconf-extract-publish-qa "journalism") (emacsconf-extract-publish-qa "handwritten" "28:36.240") ;; (kill-new (mapconcat #'emacsconf-extract-bbb-events-xml (emacsconf-get-talk-info) "")) ;; (dolist (slug '("haskell" "hyperorg" "health" "jupyter" "workflows" "wayland" "mail" "meetups" "orgsuperlinks" "rde" "science")) ;; (emacsconf-extract-publish-qa slug)) (defun emacsconf-extract-add-help-index-qa (talk) (interactive (list (emacsconf-complete-talk-info))) (if (stringp talk) (setq talk (emacsconf-resolve-talk talk))) (when (and (emacsconf-talk-file talk "--answers.vtt") (not (emacsconf-talk-file talk "--answers--chapters.vtt"))) (with-current-buffer (find-file-noselect (expand-file-name (concat (plist-get talk :slug) ".md") (expand-file-name "talks" (expand-file-name emacsconf-year emacsconf-directory)))) (goto-char (point-min)) (unless (re-search-forward "help_with_chapter_markers" nil t) (when (re-search-forward (concat (plist-get talk :slug) "-before)") nil t) (forward-line 1) (insert (format "[[!template id=\"help\" volunteer=\"\" summary=\"Q&A could be indexed with chapter markers\" tags=\"help_with_chapter_markers\" message=\"\"\"The Q&A session for this talk does not have chapter markers yet. Would you like to help? See [[help_with_chapter_markers]] for more details. You can use the vidid=\"%s-qanda\" if adding the markers to this wiki page, or e-mail your chapter notes to .\"\"\"]] " (plist-get talk :slug))) (save-buffer)))))) ;; (mapc #'emacsconf-extract-add-help-index-qa (emacsconf-prepare-for-display (emacsconf-get-talk-info))) ;; (emacsconf-extract-download-published-recordings "bbb:/var/bigbluebutton/published/presentation/" "" ~/proj/emacsconf/2023/bbb-published/"") (defvar emacsconf-extract-bbb-raw-dir "~/proj/emacsconf/2023/bbb/" "End with a \"/\".") (defvar emacsconf-extract-bbb-published-dir "~/proj/emacsconf/2023/bbb-published/" "End with a \"/\".") (defvar emacsconf-extract-conference-username "emacsconf" "Name of the streaming user.") (defvar emacsconf-extract-bbb-path "/ssh:bbb@bbb.emacsverse.org:/var/bigbluebutton/") (defun emacsconf-extract-raw-recordings-download-command () "Copy the command for downloading raw recordings." (interactive) (let ((s (mapconcat (lambda (o) (if (plist-get o :bbb-meeting-id) (format "rsync -avzue ssh %s %s\n" (expand-file-name (plist-get o :bbb-meeting-id) emacsconf-extract-bbb-path) emacsconf-extract-bbb-raw-dir) "")) (emacsconf-get-talk-info)))) (when (called-interactively-p 'any) (kill-new s)) s)) (defun emacsconf-extract-download-published-recordings-command () "Copy the command for downloading published recordings from SOURCE to DEST." (interactive) (kill-new (mapconcat (lambda (o) (if (plist-get o :bbb-meeting-id) (replace-regexp-in-string "/ssh:" "" (format "rsync -avzue ssh %s %s # %s\n" (expand-file-name (plist-get o :bbb-meeting-id) (expand-file-name "published/presentation" emacsconf-extract-bbb-path )) emacsconf-extract-bbb-published-dir (plist-get o :slug))) "")) (emacsconf-get-talk-info)))) (defun emacsconf-extract-bbb-parse-events-dir (&optional dir) (mapcar (lambda (file) (emacsconf-extract-bbb-parse-events file)) (directory-files-recursively (or dir emacsconf-extract-bbb-raw-dir) "events.xml"))) (defun emacsconf-extract-bbb-raw-events-file-name (talk) (setq talk (emacsconf-resolve-talk talk)) (expand-file-name "events.xml" (expand-file-name (plist-get talk :bbb-meeting-id) emacsconf-extract-bbb-raw-dir))) (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")))) (defun emacsconf-extract-bbb-dired-raw (talk) (interactive (list (emacsconf-complete-talk-info))) (setq talk (emacsconf-resolve-talk talk)) (dired (expand-file-name (plist-get talk :bbb-meeting-id) emacsconf-extract-bbb-raw-dir))) (defun emacsconf-extract-bbb-dired-published (talk) (interactive (list (emacsconf-complete-talk-info))) (setq talk (emacsconf-resolve-talk talk)) (dired (expand-file-name (plist-get talk :bbb-meeting-id) emacsconf-extract-bbb-published-dir))) (defun emacsconf-extract-waveform-published-webcam-video (talk) (interactive (list (emacsconf-complete-talk-info))) (setq talk (emacsconf-resolve-talk talk)) (waveform-show (expand-file-name "video/webcams.webm" (expand-file-name (plist-get talk :bbb-meeting-id) emacsconf-extract-bbb-published-dir)))) (defun emacsconf-extract-bbb-parse-events (talk) "Parse events TALK from raw recordings. This works with the events.xml from /var/bigbluebutton/raw. Files should be downloaded to `emacsconf-extract-bbb-raw-dir'." (setq talk (emacsconf-resolve-talk talk)) (let* ((xml-file (emacsconf-extract-bbb-raw-events-file-name talk)) (dom (xml-parse-file xml-file)) (meeting-name (dom-attr (dom-by-tag dom 'metadata) 'meetingName)) (meeting-id (dom-attr dom 'meeting_id)) (conf-joined (dom-search dom (lambda (o) (and (string= (dom-tag o) "name") (string= (dom-text o) emacsconf-extract-conference-username))))) (conf-joined-time (and conf-joined (string-to-number (dom-text (dom-by-tag (dom-parent dom conf-joined) 'timestampUTC))))) recording-start recording-stop recording-spans stream-start chat talking participants talking-starts deskshare-info (meeting-date (dom-text (dom-by-tag (dom-parent dom (car conf-joined)) 'date)))) (dolist (event (dom-by-tag dom 'event)) (let ((timestamp (string-to-number (dom-text (dom-by-tag event 'timestampUTC))))) (pcase (dom-attr event 'eventname) ("ParticipantJoinEvent" (push (cons (dom-text (dom-by-tag event 'userId)) (dom-text (dom-by-tag event 'name))) participants)) ("StartRecordingEvent" (setq recording-start timestamp recording-stop nil recording-file (file-name-nondirectory (dom-text (dom-by-tag event 'filename))))) ("StopRecordingEvent" (setq recording-stop timestamp) (push (cons recording-start recording-stop) recording-spans)) ("PublicChatEvent" ;; only include events in the public recording (when (and recording-start (null recording-stop) (>= timestamp recording-start)) (push (list timestamp (dom-text (dom-by-tag event 'sender)) (with-temp-buffer (insert (replace-regexp-in-string "'" "'" (replace-regexp-in-string "<.*?>" "" (dom-text (dom-by-tag event 'message))))) (mm-url-decode-entities) (buffer-string))) chat))) ("ParticipantTalkingEvent" (let* ((speaker (assoc-default (dom-text (dom-by-tag event 'participant)) participants))) (if (string= (dom-text (dom-by-tag event 'talking)) "true") ;; started talking (if (assoc-default speaker talking-starts) (setcdr (assoc speaker talking-starts) timestamp) (push (cons speaker timestamp) talking-starts)) (when (and recording-start (>= timestamp recording-start) (assoc-default speaker talking-starts) (or (null recording-stop) (<= (assoc-default speaker talking-starts) recording-stop))) (push (list speaker (- (max (assoc-default speaker talking-starts) recording-start) recording-start) (- (if recording-stop (min recording-stop timestamp) timestamp) recording-start) recording-file) talking))))) ("StartWebRTCDesktopShareEvent" (setq deskshare-info (cons (dom-text (dom-by-tag event 'filename)) timestamp))) ))) `((name . ,meeting-name) (id . ,meeting-id) (conf-joined . ,conf-joined-time) (recording-start . ,recording-start) (meeting-date . ,meeting-date) (participants . ,participants) (talking . ,(nreverse talking)) (chat . ,(nreverse chat))))) (defun emacsconf-extract-bbb-format-chat () (mapconcat (lambda (events) (format "- %s (%s)\n%s" (assoc-default 'name events) (assoc-default 'id events) (mapconcat (lambda (message) (format " - %s: %s\n" (elt message 1) (with-temp-buffer (insert (replace-regexp-in-string "'" "'" (replace-regexp-in-string "<.*?>" "" (elt message 2)))) (mm-url-decode-entities) (buffer-string)))) (assoc-default 'chat events) ""))) (emacsconf-extract-bbb-parse-events-dir) "")) (defun emacsconf-extract-spookfox-update-bbb-rec () (interactive) (let* ((data (spookfox-js-injection-eval-in-active-tab "row = [...document.querySelectorAll('.dropdown-toggle')].find((o) => o.textContent.match('Unlisted')).closest('tr'); [row.querySelector('#recording-text').getAttribute('title'), row.querySelector('a.btn-primary').getAttribute('href')]" t) ) (slug (when (and data (string-match "^\\([^(]+\\) (" (elt data 0))) (split-string (match-string 1 (elt data 0)) ", ")))) (when data (if (> (length slug) 1) (setq slug (completing-read "Talk: " slug)) (setq slug (car slug))) (emacsconf-with-talk-heading slug (if (org-entry-get (point) "BBB_REC") (progn (kill-new (elt data 1)) (error "%s already has BBB_REC?" slug)) (org-entry-put (point) "BBB_REC" (elt data 1)))) (message "Updated BBB_REC for %s to %s" slug (elt data 1)) (spookfox-js-injection-eval-in-active-tab "row = [...document.querySelectorAll('.dropdown-toggle')].find((o) => o.textContent.match('Unlisted')).closest('tr'); row.querySelector('.button_to .dropdown-item .fa-globe').closest('button').click();" t)))) (defun emacsconf-extract-chat (slug speaker) (interactive (list (emacsconf-complete-talk) (completing-read "Speaker: " (seq-uniq (mapcar (lambda (node) (dom-attr node 'name)) (dom-by-tag (xml-parse-region (point-min) (point-max)) 'chattimeline))) ))) (let ((text (mapconcat (lambda (node) (when (string= (dom-attr node 'target) "chat") (let ((message (replace-regexp-in-string "\\(^[^ +]?\\): " "" (replace-regexp-in-string "\\(.+?\\)" "<\\1>" (dom-attr node 'message))))) (if (string-match speaker (dom-attr node 'name)) (format "- %s: %s\n" speaker message) (format "- %s\n" message))))) (dom-by-tag (xml-parse-region (point-min) (point-max)) 'chattimeline) ""))) (emacsconf-edit-wiki-page slug) (if (re-search-forward "# Discussion" nil t) (progn (goto-char (match-end 0)) (insert "\n\n")) (goto-char (point-max))) (kill-new text))) ;; TODO: Combine lines from same nick, or identify speakers with anon1/2/etc. (defun emacsconf-extract-chat-from-dired () (interactive) (find-file (expand-file-name "slides_new.xml" (dired-get-file-for-visit))) (call-interactively 'emacsconf-extract-chat)) (defun emacsconf-make-webcams-deskshare-spans (talk &optional start-ms stop-ms strategy) (let* ((start-ms (or start-ms 0)) (source-dir (expand-file-name (plist-get talk :bbb-meeting-id) emacsconf-extract-bbb-published-dir)) (secs (/ start-ms 1000.0)) (deskshare (xml-parse-file (expand-file-name "deskshare.xml" source-dir))) (webcam-video (expand-file-name "video/webcams.webm" source-dir)) (deskshare-video (expand-file-name "deskshare/deskshare.webm" source-dir)) (stop-ms (or stop-ms (emacsconf-get-file-duration-ms deskshare-video))) spans) (mapc (lambda (o) (unless (or (= secs (string-to-number (dom-attr o 'start_timestamp))) (= (string-to-number (dom-attr o 'start_timestamp)) 0) (> secs (/ stop-ms 1000.0))) (setq spans (cons (list :source webcam-video :start-ms (* secs 1000) :stop-ms (* 1000 (if (eq strategy 'test) (+ secs 3) (max secs (string-to-number (dom-attr o 'start_timestamp)))))) spans))) (when (and (<= (string-to-number (dom-attr o 'start_timestamp)) (/ stop-ms 1000.0)) (>= (string-to-number (dom-attr o 'stop_timestamp)) (/ start-ms 1000.0))) (setq spans (cons (list :source deskshare-video :start-ms (max (* 1000 (string-to-number (dom-attr o 'start_timestamp))) start-ms) :stop-ms (if (eq strategy 'test) (* 1000 (+ (string-to-number (dom-attr o 'start_timestamp)) 3)) (min (* 1000 (string-to-number (dom-attr o 'stop_timestamp))) stop-ms))) spans)) (setq secs (string-to-number (dom-attr o 'stop_timestamp))))) (dom-by-tag deskshare 'event)) (unless (>= (floor (* secs 1000)) stop-ms) (setq spans (cons (list :source webcam-video :start-ms (* 1000 secs) :stop-ms (if (eq strategy 'test) (* 1000 (+ secs 3)) stop-ms)) spans))) (if (eq strategy 'test) `((video ,@(nreverse spans)) (audio ,@(mapcar (lambda (o) (list :source webcam-video :start-ms (plist-get o :start-ms) :stop-ms (plist-get o :stop-ms))) (reverse spans)))) `((video ,@(nreverse spans)) (audio (:source ,webcam-video :start-ms ,start-ms :stop-ms ,stop-ms)))))) (defun emacsconf-get-ffmpeg-to-splice-webcam-and-recording (talk &optional start-ms stop-ms info strategy) "Return FFMPEG command for slicing. Strategies: - 'fast-cut-start-keyframe - find the keyframe before the start ms and cut from there, doing a fast copy. - 'start-keyframe-and-reencode - find the keyframe before the start ms and cut from there, reencoding. - 'cut-and-concat - seek to the keyframe before, slowly find the start-ms, reencode the snippet, and then do a fast copy of the remaining. May have encoding errors. - default: copy from start-ms to stop-ms, reencoding. " (interactive (list (emacsconf-complete-talk-info))) (setq talk (emacsconf-resolve-talk talk)) (let* ((slug (plist-get talk :slug)) (start-ms (or start-ms 0)) (source-dir (expand-file-name (plist-get talk :bbb-meeting-id) emacsconf-extract-bbb-published-dir)) (video-slug (plist-get (seq-find (lambda (o) (string= (plist-get o :slug) slug)) info) :video-slug)) (output (expand-file-name (concat (plist-get talk :file-prefix) "--answers.webm") emacsconf-cache-dir)) (webcam-video (expand-file-name "video/webcams.webm" source-dir)) (deskshare-video (expand-file-name "deskshare/deskshare.webm" source-dir)) (webcam-duration (emacsconf-get-file-duration-ms webcam-video)) (stop-ms (or stop-ms webcam-duration)) (command (if (file-exists-p deskshare-video) ;; Has deskshare (let* ((deskshare (xml-parse-file (expand-file-name "deskshare.xml" source-dir))) (final-size (compile-media-max-dimensions deskshare-video webcam-video)) (duration (compile-media-get-file-duration-ms webcam-video)) (spans (emacsconf-make-webcams-deskshare-spans talk start-ms stop-ms strategy)) (compile-media-output-video-width (car final-size)) (compile-media-output-video-height (cdr final-size))) (compile-media-get-command spans output)) ;; Just webcams (if (and (= start-ms 0) (= stop-ms webcam-duration)) (format "cp %s %s" webcam-video output) (compile-media-get-command (compile-media-split-tracks (list (list :source webcam-video :start-ms start-ms :stop-ms stop-ms))) output))))) (when (called-interactively-p 'any) (kill-new command)) command)) ;; (kill-new ;; (emacsconf-extract-replace-strings ;; `((,(expand-file-name emacsconf-extract-bbb-published-dir) . "bbb-published/") ;; (,(expand-file-name emacsconf-cache-dir) . "~/current/cache")) ;; (emacsconf-get-ffmpeg-to-splice-webcam-and-recording "emacsconf"))) (defun emacsconf-extract-replace-strings (replacements s) (with-temp-buffer (insert s) (dolist (pair replacements (buffer-string)) (goto-char (point-min)) (while (re-search-forward (car pair) nil t) (replace-match (cdr pair)))) (buffer-string))) ;; Works with a table of the form ;; | Start | End | Slug | Notes | URL | Timestamp | ;; |-------+-----+------+-------+-----+-----------| (defun emacsconf-process-qa-recordings (qa dir) ;; (setq conf-qa-recordings qa) ;; (memoize 'conf-ffmpeg-get-closest-keyframe-in-msecs) ;; (memoize 'conf-ffmpeg-get-keyframes-between) ;; (memoize 'conf-video-dimensions) ;; (memoize 'compile-media-get-file-duration-ms) ;; (memoize-restore 'conf-ffmpeg-get-keyframes-around) (let ((info (emacsconf-get-talk-info))) (replace-regexp-in-string "captions/" "answers-slow/" (replace-regexp-in-string dir "" (string-join (nreverse (sort (delq nil (mapcar (lambda (o) (when (> (length (car o)) 0) (emacsconf-get-ffmpeg-to-splice-webcam-and-recording (elt o 2) (compile-media-timestamp-to-msecs (elt o 0)) (compile-media-timestamp-to-msecs (elt o 1)) info))) ; (seq-take qa 2) qa )) (lambda (a b) (string-match "trim" a)))) "\n"))))) ;;; 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_type" . "offline") ("prompt" . "consent"))) ("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-playlists (plz 'get "https://youtube.googleapis.com/youtube/v3/playlists?part=snippet,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®ionCode=CA" :headers `(("Authorization" . ,(url-oauth-auth "https://youtube.googleapis.com/youtube/v3/"))) :as #'json-read)) (setq emacsconf-extract-youtube-api-videos (emacsconf-extract-youtube-api-paginated-request (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) )) nil (lambda (item) (string-match (regexp-quote emacsconf-year) (let-alist item .snippet.title)))))) (defvar emacsconf-extract-youtube-tags '("emacs" "emacsconf")) (defun emacsconf-extract-youtube-object (video-id talk &optional privacy-status qa) "Format the video object for VIDEO-ID using TALK details. If QA is non-nil, treat it as a Q&A video." (setq privacy-status (or privacy-status "public")) (let ((properties (funcall (if qa #'emacsconf-publish-answers-video-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)) (recordingDetails (recordingDate . ,(format-time-string "%Y-%m-%dT%TZ" (plist-get talk :start-time) t)))) ;; oooh, publishing seems to work now (status (privacyStatus . ,privacy-status) (license . "creativeCommon"))))) (defun emacsconf-extract-youtube-get-slug-for-video (video-object) (let-alist video-object (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)) (t (plist-get (seq-find (lambda (o) (string-match (regexp-quote .snippet.resourceId.videoId) (or (plist-get o :youtube-url) ""))) (emacsconf-get-talk-info)) :slug))))) (defun emacsconf-extract-youtube-get-talk-for-video (video-object) (when-let ((slug (emacsconf-extract-youtube-get-slug-for-video video-object))) (emacsconf-resolve-talk slug))) (defun 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)) (defun emacsconf-extract-youtube-api-paginated-request (url &optional num-pages condition) (let (result current-page (base-url url) current-page-items) (while url (setq current-page (plz 'get url :headers `(("Authorization" . ,(url-oauth-auth "https://youtube.googleapis.com/youtube/v3/"))) :as #'json-read)) (setq current-page-items (if condition (seq-filter condition (assoc-default 'items current-page)) (assoc-default 'items current-page))) (setq result (append result current-page-items nil)) (let-alist current-page (setq url (if .nextPageToken (concat base-url (if (string-match "\\?" base-url) "&" "?") "pageToken=" .nextPageToken) nil))) (when (= (length current-page-items) 0) (setq url nil)) (when num-pages (setq num-pages (1- num-pages)) (if (<= num-pages 0) (setq url null)))) result)) (defun emacsconf-extract-youtube-api-update-video (video-object &optional qa) "Update VIDEO-OBJECT. If QA is non-nil, treat it as a Q&A video." (let-alist video-object (let* ((slug (or (emacsconf-extract-youtube-get-slug-for-video video-object) (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))))))) (talk (and slug (emacsconf-resolve-talk slug))) (video-id .snippet.resourceId.videoId) (id .id) result) (when slug ;; set the YOUTUBE_URL property (emacsconf-with-talk-heading slug (org-entry-put (point) (if qa "QA_YOUTUBE_URL" "YOUTUBE_URL") (concat "https://www.youtube.com/watch?v=" video-id)) (org-entry-put (point) (if qa "QA_YOUTUBE_ID" "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 talk nil qa))))))) (defun emacsconf-extract-youtube-rename-videos (&optional videos) "Rename videos and set the YOUTUBE_URL property in the Org heading." (interactive) (let ((info (emacsconf-get-talk-info))) (mapc (lambda (video) (let-alist video (when (string-match (rx (literal emacsconf-id) " " (literal emacsconf-year) " ") .snippet.title) (emacsconf-extract-youtube-api-update-video video)))) (assoc-default 'items (or videos emacsconf-extract-youtube-api-videos (emacsconf-extract-youtube-api-videos)))))) (defun emacsconf-extract-youtube-rename-draft-videos-as-qa (&optional videos) "Rename videos and set the QA_YOUTUBE_URL property in the Org heading." (interactive) (let ((info (emacsconf-get-talk-info))) (mapc (lambda (video) (let-alist video (when (and (string= .status.privacyStatus "private") (string-match (rx (literal emacsconf-id) " " (literal emacsconf-year) " ") .snippet.title)) (emacsconf-extract-youtube-api-update-video video t)))) (assoc-default 'items (or videos emacsconf-extract-youtube-api-videos (emacsconf-extract-youtube-api-videos)))))) ;; This still needed some tweaking, so maybe next time we'll try just inserting the items into the playlist (defvar emacsconf-extract-youtube-api-playlist nil) (defvar emacsconf-extract-youtube-api-playlist-items nil) (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)) (defun emacsconf-extract-youtube-get-video-details (&optional videos) (let (result url) (dolist (partition (seq-partition (or videos emacsconf-extract-youtube-api-videos) 50) result) (setq url (format "https://www.googleapis.com/youtube/v3/videos?id=%s&part=snippet,contentDetails,statistics" (mapconcat (lambda (item) (url-hexify-string (let-alist item .contentDetails.videoId))) partition ","))) (message "%s" url) (setq result (append result (assoc-default 'items (plz 'get url :headers `(("Authorization" . ,(url-oauth-auth "https://youtube.googleapis.com/youtube/v3/")) ("Accept" . "application/json") ("Content-Type" . "application/json")) :as #'json-read)) nil))))) (defun emacsconf-extract-youtube-duration-msecs (video) (let-alist video (when-let ((duration .contentDetails.duration)) (when (string-match "PT\\(?:\\([0-9]+\\)H\\)?\\([0-9]+\\)M\\([0-9]+\\)" duration) (+ (* (string-to-number (or (match-string 1 duration) "0")) 60 60 1000) (* (string-to-number (or (match-string 2 duration) "0")) 60 1000) (* (string-to-number (or (match-string 3 duration) "0")) 1000)))))) (defun emacsconf-extract-youtube-format-duration (s) "Converts a string of the form PT1H9M22S to 1:09:22." (when (and s (string-match "PT\\(?:\\([0-9]+\\)H\\)?\\([0-9]+\\)M\\([0-9]+\\)" s)) (concat (emacsconf-surround "" (match-string 1 s) ":") (if (match-string 1 s) (format "%02d:" (string-to-number (match-string 2 s))) (concat (match-string 2 s) ":")) (format "%02d" (string-to-number (match-string 3 s)))))) (defun emacsconf-extract-youtube-find-url-video-in-list (url &optional videos) (seq-find (lambda (o) (let-alist o (or (string-match .id url) (and .contentDetails.videoId (string-match .contentDetails.videoId url))))) (or videos emacsconf-extract-youtube-api-videos))) (ert-deftest emacsconf-extract-youtube-format-duration () (expect (emacsconf-extract-youtube-format-duration "PT1H9M22S") :to-equal "1:09:22") (expect (emacsconf-extract-youtube-format-duration "PT9M22S") :to-equal "9:22")) (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))))) (defun emacsconf-extract-youtube-store-url (&optional prefix) (interactive "p") (let* ((desc (spookfox-js-injection-eval-in-active-tab "document.querySelector('#description').innerHTML" t)) (slug (if (and desc (string-match (rx (literal emacsconf-base-url) (literal emacsconf-year) "/talks/" (group (1+ (not (or " " "/" "\""))))) desc)) (match-string 1 desc) (emacsconf-complete-slug))) (url (spookfox-js-injection-eval-in-active-tab "window.location.href" t)) (qa (or (> (or prefix 1) 1) (string-match "Q&A" (or desc "")))) (field (if qa "QA_YOUTUBE_URL" "YOUTUBE_URL"))) (save-window-excursion (emacsconf-with-talk-heading slug (org-entry-put (point) field url) (message "Updating %s %s %s" slug field url))))) ;; (setq emacsconf-extract-youtube-api-video-details (emacsconf-extract-youtube-get-video-details emacsconf-extract-youtube-api-playlist-items)) ;;; PeerTube (defvar emacsconf-extract-toobnix-api-client nil) (defvar emacsconf-extract-toobnix-api-bearer-token nil) (defvar emacsconf-extract-toobnix-api-username "bandali") (defvar emacsconf-extract-toobnix-api-channel-handle "emacsconf") (defun emacsconf-extract-toobnix-api-header () `(("Authorization" . ,(concat "Bearer " (if (stringp emacsconf-extract-toobnix-api-bearer-token) emacsconf-extract-toobnix-api-bearer-token (assoc-default 'access_token emacsconf-extract-toobnix-api-bearer-token)))))) (defun emacsconf-extract-toobnix-api-setup () (interactive) (require 'plz) (require 'url-http-oauth) (setq emacsconf-extract-toobnix-api-client (plz 'get "https://toobnix.org/api/v1/oauth-clients/local" :as #'json-read)) (setq emacsconf-extract-toobnix-api-bearer-token (assoc-default 'access_token (plz 'post "https://toobnix.org/api/v1/users/token" :body (mm-url-encode-www-form-urlencoded `(("client_id" . ,(assoc-default 'client_id emacsconf-extract-toobnix-api-client)) ("client_secret" . ,(assoc-default 'client_secret emacsconf-extract-toobnix-api-client)) ("grant_type" . "password") ("username" . ,emacsconf-extract-toobnix-api-username) ("password" . ,(auth-info-password (car (auth-source-search :host "https://toobnix.org")))))) :as #'json-read) ) ) (setq emacsconf-extract-toobnix-api-channels (plz 'get (format "https://toobnix.org/api/v1/accounts/%s/video-channels" emacsconf-extract-toobnix-api-username) :headers :as #'json-read)) (setq emacsconf-extract-toobnix-api-videos (plz 'get (format "https://toobnix.org/api/v1/accounts/%s/videos?count=100&sort=-createdAt" emacsconf-extract-toobnix-api-username) :headers (emacsconf-extract-toobnix-api-header) :as #'json-read)) (setq emacsconf-extract-toobnix-api-playlists (append (assoc-default 'data (plz 'get (format "https://toobnix.org/api/v1/video-channels/%s/video-playlists?sort=-createdAt" emacsconf-extract-toobnix-api-channel-handle) :headers (emacsconf-extract-toobnix-api-header) :as #'json-read)) nil))) (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")) (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)))) (defun emacsconf-extract-toobnix-view-qa (talk) (interactive (list (emacsconf-complete-talk-info))) (browse-url (plist-get (emacsconf-resolve-talk talk) :qa-toobnix-url))) (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)) (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))))) (defun emacsconf-extract-update-task-status-after-copying-logs () "Mark non-BBB sessions as all done." (interactive) (mapc (lambda (o) (when (and (member (plist-get o :status) '("TO_ARCHIVE" "TO_EXTRACT")) (emacsconf-talk-file o "--main.vtt") (emacsconf-captions-edited-p (emacsconf-talk-file o "--main.vtt")) (null (plist-get o :bbb-rec))) (emacsconf-with-talk-heading (plist-get o :slug) (org-todo "DONE")))) (emacsconf-publish-prepare-for-display (emacsconf-get-talk-info)))) (defun emacsconf-extract-store-url (&optional qa) "Store the URL for the currently-displayed field. Call with a prefix arg to store the URL as Q&A." (interactive (list current-prefix-arg)) (let* ((url (spookfox-js-injection-eval-in-active-tab "window.location.href" t)) (platform (if (string-match "toobnix" url) 'toobnix 'youtube)) (desc (spookfox-js-injection-eval-in-active-tab (format "document.querySelector('%s').innerHTML" (if (eq platform 'toobnix) ".video-info-description" "#description")) t)) (slug (if (and desc (string-match (rx (literal emacsconf-base-url) (literal emacsconf-year) "/talks/" (group (1+ (not (or " " "/" "\""))))) desc)) (match-string 1 desc) (emacsconf-complete-slug))) (qa (or qa (string-match "Q&A" (or desc "")))) (field (concat (if qa "QA_" "") (if (eq platform 'toobnix) "TOOBNIX" "YOUTUBE") "_URL" ))) (save-window-excursion (emacsconf-with-talk-heading slug (org-entry-put (point) field url) (message "Updating %s %s %s" slug field url))))) (provide 'emacsconf-extract) ;;; emacsconf-extract.el ends here