diff options
| -rw-r--r-- | emacsconf-extract.el | 481 | 
1 files changed, 476 insertions, 5 deletions
diff --git a/emacsconf-extract.el b/emacsconf-extract.el index 55519f4..7a1b3a9 100644 --- a/emacsconf-extract.el +++ b/emacsconf-extract.el @@ -20,6 +20,9 @@  ;;; 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) @@ -244,7 +247,9 @@  			(gethash "sentences" data)))))  ;; (emacsconf-extract-qa-from-assemblyai-sentences "~/proj/emacsconf/rms/sentences") -(defun emacsconf-extract-copy-pad () +;;;###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 "\\\\-\\\\-\\\\-\\\\-\\\\-") @@ -265,11 +270,10 @@  			(forward-line -1)  			(insert "# Discussion\n\n"))  		(save-excursion -			(unless (string= (or notes "") "") -				(insert "## Notes\n\n" notes "\n\n"))  			(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 @@ -439,10 +443,11 @@  										(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:]+\\] <\\(.*?\\)> \\([^ ]+?:\\)?\\(.+\\)$") +	(when (looking-at " *\\[[0-9:]+\\] <\\(.*?\\)> \\([^ ]+?:\\)?\\(.+\\)$")  		(let ((line (string-trim (match-string 3)))  					(prefix (or  									 prefix @@ -670,5 +675,471 @@ Would you like to help? See [[help_with_chapter_markers]] for more details. You  ;; (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 (source dest) +	"Copy the command for downloading published recordings from SOURCE to DEST." +	(kill-new +	 (mapconcat (lambda (o) (if (plist-get o :bbb-meeting-id) +															(format "rsync -avzue ssh %s%s %s\n" +																			source +																			(match-string 1 (plist-get o :bbb-rec)) +																			dest) +														"")) +							(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-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 +				 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 "<a href=\"\\(.+?\\)\" rel=\"nofollow\"><u>\\(.+?\\)</u></a>" +                                                          "<\\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)) +				 (stop-ms (or stop-ms (emacsconf-get-file-duration-ms webcam-video))) +				 (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 +						(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 + +(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®ionCode=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)  ;;; emacsconf-extract.el ends here  | 
