elfeed-tube.el (48847B)
1 ;;; elfeed-tube.el --- YouTube integration for Elfeed -*- lexical-binding: t; -*- 2 3 ;; Copyright (C) 2022 Karthik Chikmagalur 4 5 ;; Author: Karthik Chikmagalur <karthik.chikmagalur@gmail.com> 6 ;; Version: 0.10 7 ;; Package-Requires: ((emacs "27.1") (elfeed "3.4.1") (aio "1.0")) 8 ;; Keywords: news, hypermedia, convenience 9 ;; URL: https://github.com/karthink/elfeed-tube 10 11 ;; SPDX-License-Identifier: UNLICENSE 12 13 ;; This file is NOT part of GNU Emacs. 14 15 ;;; Commentary: 16 ;; 17 ;; Elfeed Tube is an extension for Elfeed, the feed reader for Emacs, that 18 ;; enhances your Youtube RSS feed subscriptions. 19 ;; 20 ;; Typically Youtube RSS feeds contain only the title and author of each video. 21 ;; Elfeed Tube adds video descriptions, thumbnails, durations, chapters and 22 ;; "live" transcrips to video entries. See 23 ;; https://github.com/karthink/elfeed-tube for demos. This information can 24 ;; optionally be added to your entry in your Elfeed database. 25 ;; 26 ;; The displayed transcripts and chapter headings are time-aware, so you can 27 ;; click on any transcript segment to visit the video at that time (in a browser 28 ;; or your video player if you also have youtube-dl). A companion package, 29 ;; `elfeed-tube-mpv', provides complete mpv (video player) integration with the 30 ;; transcript, including video seeking through the transcript and following 31 ;; along with the video in Emacs. 32 ;; 33 ;; To use this package, 34 ;; 35 ;; (i) Subscribe to Youtube channel or playlist feeds in Elfeed. You can use the 36 ;; helper function `elfeed-tube-add-feeds' provided by this package to search for 37 ;; Youtube channels by URLs or search queries. 38 ;; 39 ;; (ii) Place in your init file the following: 40 ;; 41 ;; (require 'elfeed-tube) 42 ;; (elfeed-tube-setup) 43 ;; 44 ;; (iii) Use Elfeed as normal, typically with `elfeed'. Your Youtube feed 45 ;; entries should be fully populated. 46 ;; 47 ;; You can also call `elfeed-tube-fetch' in an Elfeed buffer to manually 48 ;; populate an entry, or obtain an Elfeed entry-like summary for ANY youtube 49 ;; video (no subscription needed) by manually calling `elfeed-tube-fetch' from 50 ;; outside Elfeed. 51 ;; 52 ;; User options: 53 ;; 54 ;; There are three options of note: 55 ;; 56 ;; `elfeed-tube-fields': Customize this to set the kinds of metadata you want 57 ;; added to Elfeed's Youtube entries. You can selectively turn on/off 58 ;; thumbnails, transcripts etc. 59 ;; 60 ;; `elfeed-tube-auto-save-p': Set this boolean to save fetched Youtube metadata 61 ;; to your Elfeed database, i.e. to persist the data on disk for all entries. 62 ;; 63 ;; `elfeed-tube-auto-fetch-p': Unset this boolean to turn off fetching metadata. 64 ;; You can then call `elfeed-tube-fetch' to manually fetch data for specific 65 ;; feed entries. 66 ;; 67 ;; See the customization group `elfeed-tube' for more options. See the README 68 ;; for more information. 69 ;; 70 ;;; Code: 71 (require 'elfeed) 72 (eval-when-compile 73 (require 'cl-lib)) 74 (require 'subr-x) 75 (require 'rx) 76 (require 'aio) 77 78 (require 'elfeed-tube-utils) 79 80 ;; Customizatiion options 81 (defgroup elfeed-tube nil 82 "Elfeed-tube: View youtube details in Elfeed." 83 :group 'elfeed 84 :prefix "elfeed-tube-") 85 86 (defcustom elfeed-tube-fields 87 '(duration thumbnail description captions chapters) 88 "Metadata fields to fetch for youtube entries in Elfeed. 89 90 This is a list of symbols. The ordering is not relevant. 91 92 The choices are 93 - duration for video length, 94 - thumbnail for video thumbnail, 95 - description for video description, 96 - captions for video transcript, 97 - comments for top video comments. (NOT YET IMPLEMENTED) 98 99 Other symbols are ignored. 100 101 To set the thumbnail size, see `elfeed-tube-thumbnail-size'. 102 To set caption language(s), see `elfeed-tube-captions-languages'." 103 :group 'elfeed-tube 104 :type '(repeat (choice (const duration :tag "Duration") 105 (const thumbnail :tag "Thumbnail") 106 (const description :tag "Description") 107 (const captions :tag "Transcript")))) ;TODO 108 109 (defcustom elfeed-tube-thumbnail-size 'small 110 "Video thumbnail size to show in the Elfeed buffer. 111 112 This is a symbol. Choices are large, medium and small. Setting 113 this to nil to disable showing thumbnails, but customize 114 `elfeed-tube-fields' for that instead." 115 :group 'elfeed-tube 116 :type '(choice (const :tag "No thumbnails" nil) 117 (const :tag "Large thumbnails" large) 118 (const :tag "Medium thumbnails" medium) 119 (const :tag "Small thumbnails" small))) 120 121 (defcustom elfeed-tube-invidious-url nil 122 "Invidious URL to use for retrieving data. 123 124 Setting this is optional: If left unset, elfeed-tube will locate 125 and use an Invidious URL at random. This should be set to a 126 string, for example \"https://invidio.us\"." 127 :group 'elfeed-tube 128 :type '(choice (string :tag "Custom URL") 129 (const :tag "Disabled (Auto)" nil))) 130 131 (defcustom elfeed-tube-youtube-regexp 132 (rx bol 133 (zero-or-one (or "http://" "https://")) 134 (zero-or-one "www.") 135 (or "youtube.com/" "youtu.be/")) 136 "Regular expression to match Elfeed entry URLss against. 137 138 Only entries that match this regexp will be handled by 139 elfeed-tube when fetching information." 140 :group 'elfeed-tube 141 :type 'string) 142 143 (defcustom elfeed-tube-captions-languages 144 '("en" "english" "english (auto generated)") 145 "Caption language priority for elfeed-tube captions. 146 147 Captions in the first available langauge in this list will be 148 fetched. Each entry (string) in the list can be a language code 149 or a language name (case-insensitive, \"english\"): 150 151 - \"en\" for English 152 - \"tr\" for Turkish 153 - \"ar\" for Arabic 154 - \"de\" for German 155 - \"pt-BR\" for Portugese (Brazil), etc 156 157 Example: 158 (\"tr\" \"es\" \"arabic\" \"english\" \"english (auto generated)\") 159 160 NOTE: Language codes are safer to use. Language full names differ 161 across regions. For example, \"english\" would be spelled 162 \"englisch\" if you are in Germany." 163 :group 'elfeed-tube 164 :type '(repeat string)) 165 166 (defcustom elfeed-tube-save-indicator "[*NOT SAVED*]" 167 "Indicator to show in Elfeed entry buffers that have unsaved metadata. 168 169 This can be set to a string, which will be displayed below the 170 headers as a button. Activating this button saves the metadata to 171 the Elfeed database. 172 173 If set to any symbol except nil, it displays a minimal indicator 174 at the top of the buffer instead. 175 176 If set to nil, the indicator is disabled." 177 :group 'elfeed-tube 178 :type '(choice (const :tag "Disabled" nil) 179 (symbol :tag "Minimal" :value t) 180 (string :tag "Any string"))) 181 182 (defcustom elfeed-tube-auto-save-p nil 183 "Save information fetched by elfeed-tube to the Elfeed databse. 184 185 This is a boolean. Fetched information is automatically saved 186 when this is set to true." 187 :group 'elfeed-tube 188 :type 'boolean) 189 190 (defcustom elfeed-tube-auto-fetch-p t 191 "Fetch infor automatically when updating Elfeed or opening entries. 192 193 This is a boolean. When set to t, video information will be 194 fetched automatically when updating Elfeed or opening video 195 entries that don't have metadata." 196 :group 'elfeed-tube 197 :type 'boolean) 198 199 (defcustom elfeed-tube-captions-sblock-p t 200 "Whether sponsored segments should be de-emphasized in transcripts." 201 :group 'elfeed-tube 202 :type 'boolean) 203 204 (defcustom elfeed-tube-captions-chunk-time 30 205 "Chunk size used when displaying video transcripts. 206 207 This is the number of seconds of the transcript to chunk into 208 paragraphs or sections. It must be a positive integer." 209 :group 'elfeed-tube 210 :type 'integer) 211 212 ;; Internal variables 213 (defvar elfeed-tube--api-videos-path "/api/v1/videos/") 214 (defvar elfeed-tube--info-table (make-hash-table :test #'equal)) 215 (defvar elfeed-tube--invidious-servers nil) 216 (defvar elfeed-tube--sblock-url "https://sponsor.ajay.app") 217 (defvar elfeed-tube--sblock-api-path "/api/skipSegments") 218 (defvar elfeed-tube-captions-puntcuate-p t) 219 (defvar elfeed-tube--api-video-fields 220 '("videoThumbnails" "descriptionHtml" "lengthSeconds")) 221 (defvar elfeed-tube--max-retries 2) 222 (defvar elfeed-tube--captions-db-dir 223 ;; `file-name-concat' is 28.1+ only 224 (mapconcat #'file-name-as-directory 225 `(,elfeed-db-directory "elfeed-tube" "captions") 226 "")) 227 (defvar elfeed-tube--comments-db-dir 228 (mapconcat #'file-name-as-directory 229 `(,elfeed-db-directory "elfeed-tube" "comments") 230 "")) 231 232 (defun elfeed-tube-captions-browse-with (follow-fun) 233 "Return a command to browse thing at point with FOLLOW-FUN." 234 (lambda (event) 235 "Translate mouse event to point based button action." 236 (interactive "e") 237 (let ((pos (posn-point (event-end event)))) 238 (funcall follow-fun pos)))) 239 240 (defvar elfeed-tube-captions-map 241 (let ((map (make-sparse-keymap))) 242 (define-key map [mouse-2] (elfeed-tube-captions-browse-with 243 #'elfeed-tube--browse-at-time)) 244 map)) 245 246 (defface elfeed-tube-chapter-face 247 '((t :inherit (variable-pitch message-header-other) :weight bold)) 248 "Face used for chapter headings displayed by Elfeed Tube.") 249 250 (defface elfeed-tube-timestamp-face 251 '((t :inherit (variable-pitch message-header-other) :weight semi-bold)) 252 "Face used for transcript timestamps displayed by Elfeed Tube.") 253 254 (defvar elfeed-tube-captions-faces 255 '((text . variable-pitch) 256 (timestamp . elfeed-tube-timestamp-face) 257 (intro . (variable-pitch :inherit shadow)) 258 (outro . (variable-pitch :inherit shadow)) 259 (sponsor . (variable-pitch :inherit shadow 260 :strike-through t)) 261 (selfpromo . (variable-pitch :inherit shadow 262 :strike-through t)) 263 (chapter . elfeed-tube-chapter-face))) 264 265 ;; Helpers 266 (defsubst elfeed-tube-include-p (field) 267 "Check if FIELD should be fetched." 268 (memq field elfeed-tube-fields)) 269 270 (defsubst elfeed-tube--get-entries () 271 "Get elfeed entry at point or in active region." 272 (pcase major-mode 273 ('elfeed-search-mode 274 (elfeed-search-selected)) 275 ('elfeed-show-mode 276 (list elfeed-show-entry)))) 277 278 (defsubst elfeed-tube--youtube-p (entry) 279 "Check if ENTRY is a Youtube video entry." 280 (string-match-p elfeed-tube-youtube-regexp 281 (elfeed-entry-link entry))) 282 283 (defsubst elfeed-tube--get-video-id (entry) 284 "Get Youtube video ENTRY's video-id." 285 (when (elfeed-tube--youtube-p entry) 286 (when-let* ((link (elfeed-entry-link entry)) 287 (m (string-match 288 (concat 289 elfeed-tube-youtube-regexp 290 "\\(?:watch\\?v=\\)?" 291 "\\([^?&]+\\)") 292 link))) 293 (match-string 1 link)))) 294 295 (defsubst elfeed-tube--random-elt (collection) 296 "Random element from COLLECTION." 297 (and collection 298 (elt collection (cl-random (length collection))))) 299 300 (defsubst elfeed-tube-log (level fmt &rest objects) 301 "Log OBJECTS with FMT at LEVEL using `elfeed-log'." 302 (let ((elfeed-log-buffer-name "*elfeed-tube-log*") 303 (elfeed-log-level 'debug)) 304 (apply #'elfeed-log level fmt objects) 305 nil)) 306 307 (defsubst elfeed-tube--attempt-log (attempts) 308 "Format ATTEMPTS as a string." 309 (format "(attempt %d/%d)" 310 (1+ (- elfeed-tube--max-retries 311 attempts)) 312 elfeed-tube--max-retries)) 313 314 (defsubst elfeed-tube--thumbnail-html (thumb) 315 "HTML for inserting THUMB." 316 (when (and (elfeed-tube-include-p 'thumbnail) thumb) 317 (concat "<br><img src=\"" thumb "\"></a><br><br>"))) 318 319 (defsubst elfeed-tube--timestamp (time) 320 "Format for TIME as timestamp." 321 (format "%d:%02d" (floor time 60) (mod time 60))) 322 323 (defsubst elfeed-tube--same-entry-p (entry1 entry2) 324 "Test if elfeed ENTRY1 and ENTRY2 are the same." 325 (equal (elfeed-entry-id entry1) 326 (elfeed-entry-id entry2))) 327 328 (defsubst elfeed-tube--match-captions-langs (lang el) 329 "Find caption track matching LANG in plist EL." 330 (and (or (string-match-p 331 lang 332 (plist-get el :languageCode)) 333 (string-match-p 334 lang 335 (thread-first (plist-get el :name) 336 (plist-get :simpleText)))) 337 el)) 338 339 (defsubst elfeed-tube--truncate (str) 340 "Truncate STR." 341 (truncate-string-to-width str 20)) 342 343 (defmacro elfeed-tube--with-db (db-dir &rest body) 344 "Execute BODY with DB-DIR set as the `elfeed-db-directory'." 345 (declare (indent defun)) 346 `(let ((elfeed-db-directory ,db-dir)) 347 ,@body)) 348 349 (defsubst elfeed-tube--caption-get-face (type) 350 "Get caption face for TYPE." 351 (or (alist-get type elfeed-tube-captions-faces) 352 'variable-pitch)) 353 354 ;; Data structure 355 (cl-defstruct 356 (elfeed-tube-item (:constructor elfeed-tube-item--create) 357 (:copier nil)) 358 "Struct to hold elfeed-tube metadata." 359 len thumb desc caps error) 360 361 ;; Persistence 362 (defun elfeed-tube--write-db (entry &optional data-item) 363 "Write struct DATA-ITEM to Elfeed ENTRY in `elfeed-db'." 364 (cl-assert (elfeed-entry-p entry)) 365 (when-let* ((data-item (or data-item (elfeed-tube--gethash entry)))) 366 (when (elfeed-tube-include-p 'description) 367 (setf (elfeed-entry-content-type entry) 'html) 368 (setf (elfeed-entry-content entry) 369 (when-let ((desc (elfeed-tube-item-desc data-item))) 370 (elfeed-ref desc)))) 371 (when (elfeed-tube-include-p 'duration) 372 (setf (elfeed-meta entry :duration) 373 (elfeed-tube-item-len data-item))) 374 (when (elfeed-tube-include-p 'thumbnail) 375 (setf (elfeed-meta entry :thumb) 376 (elfeed-tube-item-thumb data-item))) 377 (when (elfeed-tube-include-p 'captions) 378 (elfeed-tube--with-db elfeed-tube--captions-db-dir 379 (setf (elfeed-meta entry :caps) 380 (when-let ((caption (elfeed-tube-item-caps data-item))) 381 (elfeed-ref (prin1-to-string caption)))))) 382 (elfeed-tube-log 'info "[DB][Wrote to DB][video:%s]" 383 (elfeed-tube--truncate (elfeed-entry-title entry))) 384 t)) 385 386 (defun elfeed-tube--gethash (entry) 387 "Get hashed elfeed-tube data for ENTRY." 388 (cl-assert (elfeed-entry-p entry)) 389 (let ((video-id (elfeed-tube--get-video-id entry))) 390 (gethash video-id elfeed-tube--info-table))) 391 392 (defun elfeed-tube--puthash (entry data-item &optional force) 393 "Cache elfeed-dube-item DATA-ITEM for ENTRY." 394 (cl-assert (elfeed-entry-p entry)) 395 (cl-assert (elfeed-tube-item-p data-item)) 396 (when-let* ((video-id (elfeed-tube--get-video-id entry)) 397 (f (or force 398 (not (gethash video-id elfeed-tube--info-table))))) 399 ;; (elfeed-tube--message 400 ;; (format "putting %s with data %S" video-id data-item)) 401 (puthash video-id data-item elfeed-tube--info-table))) 402 403 ;; Data munging 404 (defun elfeed-tube--get-chapters (desc) 405 "Get chapter timestamps from video DESC." 406 (with-temp-buffer 407 (let ((chapters)) 408 (save-excursion (insert desc)) 409 (while (re-search-forward 410 "<a href=.*?data-jump-time=\"\\([0-9]+\\)\".*?</a>\\(?:\\s-\\|\\s)\\|-\\)+\\(.*\\)$" nil t) 411 (push (cons (match-string 1) 412 (thread-last (match-string 2) 413 (replace-regexp-in-string 414 """ "\"") 415 (replace-regexp-in-string 416 "'" "'") 417 (replace-regexp-in-string 418 "&" "&") 419 (string-trim))) 420 chapters)) 421 (nreverse chapters)))) 422 423 (defun elfeed-tube--parse-desc (api-data) 424 "Parse API-DATA for video description." 425 (let* ((length-seconds (plist-get api-data :lengthSeconds)) 426 (desc-html (plist-get api-data :descriptionHtml)) 427 (chapters (elfeed-tube--get-chapters desc-html)) 428 (desc-html (replace-regexp-in-string 429 "\n" "<br>" 430 desc-html)) 431 (thumb-alist '((large . 2) 432 (medium . 3) 433 (small . 4))) 434 (thumb-size (cdr-safe (assoc elfeed-tube-thumbnail-size 435 thumb-alist))) 436 (thumb)) 437 (when (and (elfeed-tube-include-p 'thumbnail) 438 thumb-size) 439 (setq thumb (thread-first 440 (plist-get api-data :videoThumbnails) 441 (aref thumb-size) 442 (plist-get :url)))) 443 `(:length ,length-seconds :thumb ,thumb :desc ,desc-html 444 :chaps ,chapters))) 445 446 (defun elfeed-tube--extract-captions-urls () 447 "Extract captionn URLs from Youtube HTML." 448 (catch 'parse-error 449 (if (not (search-forward "\"captions\":" nil t)) 450 (throw 'parse-error "captions section not found") 451 (delete-region (point-min) (point)) 452 (if (not (search-forward ",\"videoDetails" nil t)) 453 (throw 'parse-error "video details not found") 454 (goto-char (match-beginning 0)) 455 (delete-region (point) (point-max)) 456 (goto-char (point-min)) 457 (save-excursion 458 (while (search-forward "\n" nil t) 459 (delete-region (match-beginning 0) (match-end 0)))) 460 (condition-case nil 461 (json-parse-buffer :object-type 'plist 462 :array-type 'list) 463 (json-parse-error (throw 'parse-error "json-parse-error"))))))) 464 465 (defun elfeed-tube--postprocess-captions (text) 466 "Tweak TEXT for display in the transcript." 467 (thread-last 468 ;; (string-replace "\n" " " text) 469 (replace-regexp-in-string "\n" " " text) 470 (replace-regexp-in-string "\\bi\\b" "I") 471 (replace-regexp-in-string 472 (rx (group (syntax open-parenthesis)) 473 (one-or-more (or space punct))) 474 "\\1") 475 (replace-regexp-in-string 476 (rx (one-or-more (or space punct)) 477 (group (syntax close-parenthesis))) 478 "\\1"))) 479 480 ;; Content display 481 (defvar elfeed-tube--save-state-map 482 (let ((map (make-sparse-keymap))) 483 (define-key map [mouse-2] #'elfeed-tube-save) 484 ;; (define-key map (kbd "RET") #'elfeed-tube--browse-at-time) 485 (define-key map [follow-link] 'mouse-face) 486 map)) 487 488 (defun elfeed-tube-show (&optional intended-entry) 489 "Show extra video information in an Elfeed entry buffer. 490 491 INTENDED-ENTRY is the Elfeed entry being shown. If it is not 492 specified use the entry (if any) being displayed in the current 493 buffer." 494 (when-let* ((show-buf 495 (if intended-entry 496 (get-buffer (elfeed-show--buffer-name intended-entry)) 497 (and (elfeed-tube--youtube-p elfeed-show-entry) 498 (current-buffer)))) 499 (entry (buffer-local-value 'elfeed-show-entry show-buf)) 500 (intended-entry (or intended-entry entry))) 501 (when (elfeed-tube--same-entry-p entry intended-entry) 502 (with-current-buffer show-buf 503 (let* ((inhibit-read-only t) 504 (feed (elfeed-entry-feed elfeed-show-entry)) 505 (base (and feed (elfeed-compute-base (elfeed-feed-url feed)))) 506 (data-item (elfeed-tube--gethash entry)) 507 insertions) 508 509 (goto-char (point-max)) 510 (when (text-property-search-backward 511 'face 'message-header-name) 512 (beginning-of-line) 513 (when (looking-at "Transcript:") 514 (text-property-search-backward 515 'face 'message-header-name) 516 (beginning-of-line))) 517 518 ;; Duration 519 (if-let ((d (elfeed-tube-include-p 'duration)) 520 (duration 521 (or (and data-item (elfeed-tube-item-len data-item)) 522 (elfeed-meta entry :duration)))) 523 (elfeed-tube--insert-duration entry duration) 524 (forward-line 1)) 525 526 ;; DB Status 527 (when (and 528 elfeed-tube-save-indicator 529 (or (and data-item (elfeed-tube-item-desc data-item) 530 (not (elfeed-entry-content entry))) 531 (and data-item (elfeed-tube-item-thumb data-item) 532 (not (elfeed-meta entry :thumb))) 533 (and data-item (elfeed-tube-item-caps data-item) 534 (not (elfeed-meta entry :caps))))) 535 (let ((prop-list 536 `(face (:inherit warning :weight bold) mouse-face highlight 537 help-echo "mouse-1: save this entry to the elfeed-db" 538 keymap ,elfeed-tube--save-state-map))) 539 (if (stringp elfeed-tube-save-indicator) 540 (insert (apply #'propertize 541 elfeed-tube-save-indicator 542 prop-list) 543 "\n") 544 (save-excursion 545 (goto-char (point-min)) 546 (end-of-line) 547 (insert " " (apply #'propertize "[∗]" prop-list)))))) 548 549 ;; Thumbnail 550 (when-let ((th (elfeed-tube-include-p 'thumbnail)) 551 (thumb (or (and data-item (elfeed-tube-item-thumb data-item)) 552 (elfeed-meta entry :thumb)))) 553 (elfeed-insert-html (elfeed-tube--thumbnail-html thumb)) 554 (push 'thumb insertions)) 555 556 ;; Description 557 (delete-region (point) (point-max)) 558 (when (elfeed-tube-include-p 'description) 559 (if-let ((desc (or (and data-item (elfeed-tube-item-desc data-item)) 560 (elfeed-deref (elfeed-entry-content entry))))) 561 (progn (elfeed-insert-html (concat desc "") base) 562 (push 'desc insertions)))) 563 564 ;; Captions 565 (elfeed-tube--with-db elfeed-tube--captions-db-dir 566 (when-let* ((c (elfeed-tube-include-p 'captions)) 567 (caption 568 (or (and data-item (elfeed-tube-item-caps data-item)) 569 (and (when-let 570 ((capstr (elfeed-deref 571 (elfeed-meta entry :caps)))) 572 (condition-case nil 573 (read capstr) 574 ('error 575 (elfeed-tube-log 576 'error "[Show][Captions] DB parse error: %S" 577 (elfeed-meta entry :caps))))))))) 578 (when (not (elfeed-entry-content entry)) 579 (kill-region (point) (point-max))) 580 (elfeed-tube--insert-captions caption) 581 (push 'caps insertions))) 582 583 (if insertions 584 (delete-region (point) (point-max)) 585 (insert (propertize "\n(empty)\n" 'face 'italic)))) 586 587 (setq-local 588 imenu-prev-index-position-function #'elfeed-tube-prev-heading 589 imenu-extract-index-name-function #'elfeed-tube--line-at-point) 590 591 (goto-char (point-min)))))) 592 593 (defun elfeed-tube--insert-duration (entry duration) 594 "Insert the video DURATION for ENTRY into an Elfeed entry buffer." 595 (if (not (integerp duration)) 596 (elfeed-tube-log 597 'warn "[Duration][video:%s][Not available]" 598 (elfeed-tube--truncate (elfeed-entry-title entry))) 599 (let ((inhibit-read-only t)) 600 (beginning-of-line) 601 (if (looking-at "Duration:") 602 (delete-region (point) 603 (save-excursion (end-of-line) 604 (point))) 605 (end-of-line) 606 (insert "\n")) 607 (insert (propertize "Duration: " 'face 'message-header-name) 608 (propertize (elfeed-tube--timestamp duration) 609 'face 'message-header-other) 610 "\n") 611 t))) 612 613 (defun elfeed-tube--insert-captions (caption) 614 "Insert the video CAPTION for ENTRY into an Elfeed entry buffer." 615 (if (and (listp caption) 616 (eq (car-safe caption) 'transcript)) 617 (let ((caption-ordered 618 (cl-loop for (type (start _) text) in (cddr caption) 619 with chapters = (car-safe (cdr caption)) 620 with pstart = 0 621 for chapter = (car-safe chapters) 622 for oldtime = 0 then time 623 for time = (string-to-number (cdr start)) 624 for chap-begin-p = 625 (and chapter 626 (>= (floor time) (string-to-number (car chapter)))) 627 628 if (and 629 (or chap-begin-p 630 (< (mod (floor time) 631 elfeed-tube-captions-chunk-time) 632 (mod (floor oldtime) 633 elfeed-tube-captions-chunk-time))) 634 (> (abs (- time pstart)) 3)) 635 collect (list pstart time para) into result and 636 do (setq para nil pstart time) 637 638 if chap-begin-p 639 do (setq chapters (cdr-safe chapters)) 640 641 collect (cons time 642 (propertize 643 ;; (elfeed-tube--postprocess-captions text) 644 (replace-regexp-in-string "\n" " " text) 645 'face (elfeed-tube--caption-get-face type) 646 'type type)) 647 into para 648 finally return (nconc result (list (list pstart time para))))) 649 (inhibit-read-only t)) 650 (goto-char (point-max)) 651 (insert "\n" 652 (propertize "Transcript:" 'face 'message-header-name) 653 "\n\n") 654 (cl-loop for (start end para) in caption-ordered 655 with chapters = (car-safe (cdr caption)) 656 with vspace = (propertize " " 'face 'variable-pitch) 657 for chapter = (car-safe chapters) 658 with beg = (point) do 659 (progn 660 (when (and chapter (>= start (string-to-number (car chapter)))) 661 (insert (propertize (cdr chapter) 662 'face 663 (elfeed-tube--caption-get-face 'chapter) 664 'timestamp (string-to-number (car chapter)) 665 'mouse-face 'highlight 666 'help-echo #'elfeed-tube--caption-echo 667 'keymap elfeed-tube-captions-map 668 'type 'chapter) 669 (propertize "\n\n" 'hard t)) 670 (setq chapters (cdr chapters))) 671 (insert 672 (propertize (format "[%s] - [%s]:" 673 (elfeed-tube--timestamp start) 674 (elfeed-tube--timestamp end)) 675 'face (elfeed-tube--caption-get-face 676 'timestamp)) 677 (propertize "\n" 'hard t) 678 (string-join 679 (mapcar (lambda (tx-cons) 680 (propertize (cdr tx-cons) 681 'timestamp 682 (car tx-cons) 683 'mouse-face 684 'highlight 685 'help-echo 686 #'elfeed-tube--caption-echo 687 'keymap 688 elfeed-tube-captions-map)) 689 para) 690 vspace) 691 (propertize "\n\n" 'hard t))) 692 finally (when-let* ((w shr-width) 693 (fill-column w) 694 (use-hard-newlines t)) 695 (fill-region beg (point) nil t)))) 696 (elfeed-tube-log 'debug 697 "[Captions][video:%s][Not available]" 698 (or (and elfeed-show-entry (truncate-string-to-width 699 elfeed-show-entry 20)) 700 "")))) 701 702 (defvar elfeed-tube--captions-echo-message 703 (lambda (time) (format "mouse-2: open at %s (web browser)" time))) 704 705 (defun elfeed-tube--caption-echo (_ _ pos) 706 "Caption echo text at position POS." 707 (concat 708 (when-let ((type (get-text-property pos 'type))) 709 (when (not (eq type 'text)) 710 (format " segment: %s\n\n" (symbol-name type)))) 711 (let ((time (elfeed-tube--timestamp 712 (get-text-property pos 'timestamp)))) 713 (funcall elfeed-tube--captions-echo-message time)))) 714 715 ;; Setup 716 (defun elfeed-tube--auto-fetch (&optional entry) 717 "Fetch video information for Elfeed ENTRY and display it if possible. 718 719 If ENTRY is not specified, use the entry (if any) corresponding 720 to the current buffer." 721 (when elfeed-tube-auto-fetch-p 722 (aio-listen 723 (elfeed-tube--fetch-1 (or entry elfeed-show-entry)) 724 (lambda (fetched-p) 725 (when (funcall fetched-p) 726 (elfeed-tube-show (or entry elfeed-show-entry))))))) 727 728 (defun elfeed-tube-setup () 729 "Set up elfeed-tube. 730 731 This does the following: 732 - Enable fetching video metadata when running `elfeed-update'. 733 - Enable showing video metadata in `elfeed-show' buffers if available." 734 (add-hook 'elfeed-new-entry-hook #'elfeed-tube--auto-fetch) 735 (advice-add 'elfeed-show-entry 736 :after #'elfeed-tube--auto-fetch) 737 (advice-add elfeed-show-refresh-function 738 :after #'elfeed-tube-show) 739 t) 740 741 (defun elfeed-tube-teardown () 742 "Undo the effects of `elfeed-tube-setup'." 743 (advice-remove elfeed-show-refresh-function #'elfeed-tube-show) 744 (advice-remove 'elfeed-show-entry #'elfeed-tube--auto-fetch) 745 (remove-hook 'elfeed-new-entry-hook #'elfeed-tube--auto-fetch) 746 t) 747 748 ;; From aio-contrib.el: the workhorse 749 (defun elfeed-tube-curl-enqueue (url &rest args) 750 "Fetch URL with ARGS using Curl. 751 752 Like `elfeed-curl-enqueue' but delivered by a promise. 753 754 The result is a plist with the following keys: 755 :success -- the callback argument (t or nil) 756 :headers -- `elfeed-curl-headers' 757 :status-code -- `elfeed-curl-status-code' 758 :error-message -- `elfeed-curl-error-message' 759 :location -- `elfeed-curl-location' 760 :content -- (buffer-string)" 761 (let* ((promise (aio-promise)) 762 (cb (lambda (success) 763 (let ((result (list :success success 764 :headers elfeed-curl-headers 765 :status-code elfeed-curl-status-code 766 :error-message elfeed-curl-error-message 767 :location elfeed-curl-location 768 :content (buffer-string)))) 769 (aio-resolve promise (lambda () result)))))) 770 (prog1 promise 771 (apply #'elfeed-curl-enqueue url cb args)))) 772 773 ;; Fetchers 774 (aio-defun elfeed-tube--get-invidious-servers () 775 (let* ((instances-url (concat "https://api.invidious.io/instances.json" 776 "?pretty=1&sort_by=type,users")) 777 (result (aio-await (elfeed-tube-curl-enqueue instances-url :method "GET"))) 778 (status-code (plist-get result :status-code)) 779 (servers (plist-get result :content))) 780 (when (= status-code 200) 781 (thread-last 782 (json-parse-string servers :object-type 'plist :array-type 'list) 783 (cl-remove-if-not (lambda (s) (eq t (plist-get (cadr s) :api)))) 784 (mapcar #'car))))) 785 786 (aio-defun elfeed-tube--get-invidious-url () 787 (or elfeed-tube-invidious-url 788 (let ((servers 789 (or elfeed-tube--invidious-servers 790 (setq elfeed-tube--invidious-servers 791 (elfeed--shuffle 792 (aio-await (elfeed-tube--get-invidious-servers))))))) 793 (car servers)))) 794 795 (defsubst elfeed-tube--nrotate-invidious-servers () 796 "Rotate the list of Invidious servers in place." 797 (setq elfeed-tube--invidious-servers 798 (nconc (cdr elfeed-tube--invidious-servers) 799 (list (car elfeed-tube--invidious-servers))))) 800 801 (aio-defun elfeed-tube--fetch-captions-tracks (entry) 802 (let* ((video-id (elfeed-tube--get-video-id entry)) 803 (url (format "https://youtube.com/watch?v=%s" video-id)) 804 (response (aio-await (elfeed-tube-curl-enqueue url :method "GET"))) 805 (status-code (plist-get response :status-code))) 806 (if-let* 807 ((s (= status-code 200)) 808 (data (with-temp-buffer 809 (save-excursion (insert (plist-get response :content))) 810 (elfeed-tube--extract-captions-urls)))) 811 ;; (message "%S" data) 812 (thread-first 813 data 814 (plist-get :playerCaptionsTracklistRenderer) 815 (plist-get :captionTracks)) 816 (elfeed-tube-log 'debug "[%s][Caption tracks]: %s" 817 url (plist-get response :error-message)) 818 (elfeed-tube-log 'warn "[Captions][video:%s]: Not available" 819 (elfeed-tube--truncate (elfeed-entry-title entry)))))) 820 821 (aio-defun elfeed-tube--fetch-captions-url (caption-plist entry) 822 (let* ((case-fold-search t) 823 (chosen-caption 824 (cl-loop 825 for lang in elfeed-tube-captions-languages 826 for pick = (cl-some 827 (lambda (el) (elfeed-tube--match-captions-langs lang el)) 828 caption-plist) 829 until pick 830 finally return pick)) 831 base-url language) 832 (cond 833 ((not caption-plist) 834 (elfeed-tube-log 835 'warn "[Captions][video:%s][No languages]" 836 (elfeed-tube--truncate (elfeed-entry-title entry)))) 837 ((not chosen-caption) 838 (elfeed-tube-log 839 'warn 840 "[Captions][video:%s][Not available in %s]" 841 (elfeed-tube--truncate (elfeed-entry-title entry)) 842 (string-join elfeed-tube-captions-languages ", "))) 843 (t (setq base-url (plist-get chosen-caption :baseUrl) 844 language (thread-first (plist-get chosen-caption :name) 845 (plist-get :simpleText))) 846 (let* ((response (aio-await (elfeed-tube-curl-enqueue base-url :method "GET"))) 847 (captions (plist-get response :content)) 848 (status-code (plist-get response :status-code))) 849 (if (= status-code 200) 850 (cons language captions) 851 (elfeed-tube-log 852 'error 853 "[Caption][video:%s][lang:%s]: %s" 854 (elfeed-tube--truncate (elfeed-entry-title entry)) 855 language 856 (plist-get response :error-message)))))))) 857 858 (defvar elfeed-tube--sblock-categories 859 '("sponsor" "intro" "outro" "selfpromo" "interaction")) 860 861 (aio-defun elfeed-tube--fetch-captions-sblock (entry) 862 (when-let* ((categories 863 (json-serialize (vconcat elfeed-tube--sblock-categories))) 864 (api-url (url-encode-url 865 (concat elfeed-tube--sblock-url 866 elfeed-tube--sblock-api-path 867 "?videoID=" (elfeed-tube--get-video-id entry) 868 "&categories=" categories))) 869 (response (aio-await (elfeed-tube-curl-enqueue 870 api-url :method "GET"))) 871 (status-code (plist-get response :status-code)) 872 (content-json (plist-get response :content))) 873 (if (= status-code 200) 874 (condition-case nil 875 (json-parse-string content-json 876 :object-type 'plist 877 :array-type 'list) 878 (json-parse-error 879 (elfeed-tube-log 880 'error 881 "[Sponsorblock][video:%s]: JSON malformed" 882 (elfeed-tube--truncate (elfeed-entry-title entry))))) 883 (elfeed-tube-log 884 'error 885 "[Sponsorblock][video:%s]: %s" 886 (elfeed-tube--truncate (elfeed-entry-title entry)) 887 (plist-get response :error-message))))) 888 889 (aio-defun elfeed-tube--fetch-captions (entry) 890 (pcase-let* ((urls (aio-await (elfeed-tube--fetch-captions-tracks entry))) 891 (`(,language . ,xmlcaps) (aio-await (elfeed-tube--fetch-captions-url urls entry))) 892 (sblock (and elfeed-tube-captions-sblock-p 893 (aio-await (elfeed-tube--fetch-captions-sblock entry)))) 894 (parsed-caps)) 895 ;; (print (elfeed-entry-title entry) (get-buffer "*scratch*")) 896 ;; (print language (get-buffer "*scratch*")) 897 (when xmlcaps 898 (setq parsed-caps (with-temp-buffer 899 (insert xmlcaps) 900 (goto-char (point-min)) 901 (dolist (reps '(("&#39;" . "'") 902 ("&quot;" . "\"") 903 ("\n" . " ") 904 (" " . ""))) 905 (save-excursion 906 (while (search-forward (car reps) nil t) 907 (replace-match (cdr reps) nil t)))) 908 (libxml-parse-xml-region (point-min) (point-max))))) 909 (when parsed-caps 910 (when (and elfeed-tube-captions-sblock-p sblock) 911 (setq parsed-caps (elfeed-tube--sblock-captions sblock parsed-caps))) 912 (when (and elfeed-tube-captions-puntcuate-p 913 (string-match-p "auto-generated" language)) 914 (elfeed-tube--npreprocess-captions parsed-caps)) 915 parsed-caps))) 916 917 (defun elfeed-tube--npreprocess-captions (captions) 918 "Preprocess CAPTIONS." 919 (cl-loop for text-element in (cddr captions) 920 for (_ _ text) in (cddr captions) 921 do (setf (nth 2 text-element) 922 (cl-reduce 923 (lambda (accum reps) 924 (replace-regexp-in-string (car reps) (cdr reps) accum)) 925 `(("\\bi\\b" . "I") 926 (,(rx (group (syntax open-parenthesis)) 927 (one-or-more (or space punct))) 928 . "\\1") 929 (,(rx (one-or-more (or space punct)) 930 (group (syntax close-parenthesis))) 931 . "\\1")) 932 :initial-value text)) 933 finally return captions)) 934 935 (defun elfeed-tube--sblock-captions (sblock captions) 936 "Add sponsor data from SBLOCK into CAPTIONS." 937 (let ((sblock-filtered 938 (cl-loop for skip in sblock 939 for cat = (plist-get skip :category) 940 when (member cat elfeed-tube--sblock-categories) 941 collect `(:category ,cat :segment ,(plist-get skip :segment))))) 942 (cl-loop for telm in (cddr captions) 943 do (when-let 944 ((cat 945 (cl-some 946 (lambda (skip) 947 (pcase-let ((cat (intern (plist-get skip :category))) 948 (`(,beg ,end) (plist-get skip :segment)) 949 (sn (string-to-number (cdaadr telm)))) 950 (and (> sn beg) (< sn end) cat))) 951 sblock-filtered))) 952 (setf (car telm) cat)) 953 finally return captions))) 954 955 (aio-defun elfeed-tube--fetch-desc (entry &optional attempts) 956 (let* ((attempts (or attempts (1+ elfeed-tube--max-retries))) 957 (video-id (elfeed-tube--get-video-id entry))) 958 (when (> attempts 0) 959 (if-let ((invidious-url (aio-await (elfeed-tube--get-invidious-url)))) 960 (let* ((api-url (concat 961 invidious-url 962 elfeed-tube--api-videos-path 963 video-id 964 "?fields=" 965 (string-join elfeed-tube--api-video-fields ","))) 966 (desc-log (elfeed-tube-log 967 'debug 968 "[Description][video:%s][Fetch:%s]" 969 (elfeed-tube--truncate (elfeed-entry-title entry)) 970 api-url)) 971 (api-response (aio-await (elfeed-tube-curl-enqueue 972 api-url 973 :method "GET"))) 974 (api-status (plist-get api-response :status-code)) 975 (api-data (plist-get api-response :content)) 976 (json-object-type (quote plist))) 977 (if (= api-status 200) 978 ;; Return data 979 (condition-case error 980 (prog1 981 (elfeed-tube--parse-desc 982 (json-parse-string api-data :object-type 'plist))) 983 (json-parse-error 984 (elfeed-tube-log 985 'error 986 "[Description][video:%s]: JSON malformed %s" 987 (elfeed-tube--truncate (elfeed-entry-title entry)) 988 (elfeed-tube--attempt-log attempts)) 989 (elfeed-tube--nrotate-invidious-servers) 990 (aio-await 991 (elfeed-tube--fetch-desc entry (- attempts 1))))) 992 ;; Retry #attempts times 993 (elfeed-tube-log 'error 994 "[Description][video:%s][%s]: %s %s" 995 (elfeed-tube--truncate (elfeed-entry-title entry)) 996 api-url 997 (plist-get api-response :error-message) 998 (elfeed-tube--attempt-log attempts)) 999 (elfeed-tube--nrotate-invidious-servers) 1000 (aio-await 1001 (elfeed-tube--fetch-desc entry (- attempts 1))))) 1002 1003 (message 1004 "Could not find a valid Invidious url. Please customize `elfeed-tube-invidious-url'.") 1005 nil)))) 1006 1007 (aio-defun elfeed-tube--with-label (label func &rest args) 1008 (cons label (aio-await (apply func args)))) 1009 1010 (aio-defun elfeed-tube--fetch-1 (entry &optional force-fetch) 1011 (when (elfeed-tube--youtube-p entry) 1012 (let* ((fields (aio-make-select)) 1013 (cached (elfeed-tube--gethash entry)) 1014 desc thumb duration caps sblock chaps error) 1015 1016 ;; When to fetch a field: 1017 ;; - force-fetch is true: always fetch 1018 ;; - entry not cached, field not saved: fetch 1019 ;; - entry not cached but saved: don't fetch 1020 ;; - entry is cached with errors: don't fetch 1021 ;; - entry is cached without errors, field not empty: don't fetch 1022 ;; - entry is saved and field not empty: don't fetch 1023 1024 ;; Fetch description? 1025 (when (and (cl-some #'elfeed-tube-include-p 1026 '(description duration thumbnail chapters)) 1027 (or force-fetch 1028 (not (or (and cached 1029 (or (cl-intersection 1030 '(desc duration thumb chapters) 1031 (elfeed-tube-item-error cached)) 1032 (elfeed-tube-item-len cached) 1033 (elfeed-tube-item-desc cached) 1034 (elfeed-tube-item-thumb cached))) 1035 (or (elfeed-entry-content entry) 1036 (elfeed-meta entry :thumb) 1037 (elfeed-meta entry :duration)))))) 1038 (aio-select-add fields 1039 (elfeed-tube--with-label 1040 'desc #'elfeed-tube--fetch-desc entry))) 1041 1042 ;; Fetch captions? 1043 (when (and (elfeed-tube-include-p 'captions) 1044 (or force-fetch 1045 (not (or (and cached 1046 (or (elfeed-tube-item-caps cached) 1047 (memq 'caps (elfeed-tube-item-error cached)))) 1048 (elfeed-ref-p 1049 (elfeed-meta entry :caps)))))) 1050 (aio-select-add fields 1051 (elfeed-tube--with-label 1052 'caps #'elfeed-tube--fetch-captions entry)) 1053 ;; Fetch caption sblocks? 1054 (when (and nil elfeed-tube-captions-sblock-p) 1055 (aio-select-add fields 1056 (elfeed-tube--with-label 1057 'sblock #'elfeed-tube--fetch-captions-sblock entry)))) 1058 1059 ;; Record fields? 1060 (while (aio-select-promises fields) 1061 (pcase-let ((`(,label . ,data) 1062 (aio-await (aio-await (aio-select fields))))) 1063 (pcase label 1064 ('desc 1065 (if data 1066 (progn 1067 (when (elfeed-tube-include-p 'thumbnail) 1068 (setf thumb 1069 (plist-get data :thumb))) 1070 (when (elfeed-tube-include-p 'description) 1071 (setf desc 1072 (plist-get data :desc))) 1073 (when (elfeed-tube-include-p 'duration) 1074 (setf duration 1075 (plist-get data :length))) 1076 (when (elfeed-tube-include-p 'chapters) 1077 (setf chaps 1078 (plist-get data :chaps)))) 1079 (setq error (append error '(desc duration thumb))))) 1080 ('caps 1081 (if data 1082 (setf caps data) 1083 (push 'caps error))) 1084 ('sblock 1085 (and data (setf sblock data)))))) 1086 1087 ;; Add (optional) sblock and chapter info to caps 1088 (when caps 1089 (when sblock 1090 (setf caps (elfeed-tube--sblock-captions sblock caps))) 1091 (when chaps 1092 (setf (cadr caps) chaps))) 1093 1094 (if (and elfeed-tube-auto-save-p 1095 (or duration caps desc thumb)) 1096 ;; Store in db 1097 (progn (elfeed-tube--write-db 1098 entry 1099 (elfeed-tube-item--create 1100 :len duration :desc desc :thumb thumb 1101 :caps caps)) 1102 (elfeed-tube-log 1103 'info "Saved to elfeed-db: %s" 1104 (elfeed-entry-title entry))) 1105 ;; Store in session cache 1106 (when (or duration caps desc thumb error) 1107 (elfeed-tube--puthash 1108 entry 1109 (elfeed-tube-item--create 1110 :len duration :desc desc :thumb thumb 1111 :caps caps :error error) 1112 force-fetch))) 1113 ;; Return t if something was fetched 1114 (and (or duration caps desc thumb) t)))) 1115 1116 ;; Interaction 1117 (defun elfeed-tube--browse-at-time (pos) 1118 "Browse video URL at POS at current time." 1119 (interactive "d") 1120 (when-let ((time (get-text-property pos 'timestamp))) 1121 (browse-url (concat "https://youtube.com/watch?v=" 1122 (elfeed-tube--get-video-id elfeed-show-entry) 1123 "&t=" 1124 (number-to-string (floor time)))))) 1125 1126 ;; Entry points 1127 ;;;###autoload (autoload 'elfeed-tube-fetch "elfeed-tube" "Fetch youtube metadata for Youtube video or Elfeed entry ENTRIES." t nil) 1128 (aio-defun elfeed-tube-fetch (entries &optional force-fetch) 1129 "Fetch youtube metadata for Elfeed ENTRIES. 1130 1131 In elfeed-show buffers, ENTRIES is the entry being displayed. 1132 1133 In elfeed-search buffers, ENTRIES is the entry at point, or all 1134 entries in the region when the region is active. 1135 1136 Outside of Elfeed, prompt the user for any Youtube video URL and 1137 generate an Elfeed-like summary buffer for it. 1138 1139 With optional prefix argument FORCE-FETCH, force refetching of 1140 the metadata for ENTRIES. 1141 1142 If you want to always add this metadata to the database, consider 1143 setting `elfeed-tube-auto-save-p'. To customize what kinds of 1144 metadata are fetched, customize TODO 1145 `elfeed-tube-fields'." 1146 (interactive (list (or (elfeed-tube--ensure-list (elfeed-tube--get-entries)) 1147 (read-from-minibuffer "Youtube video URL: ")) 1148 current-prefix-arg)) 1149 (if (not (listp entries)) 1150 (elfeed-tube--fake-entry entries force-fetch) 1151 (if (not elfeed-tube-fields) 1152 (message "Nothing to fetch! Customize `elfeed-tube-fields'.") 1153 (dolist (entry (elfeed-tube--ensure-list entries)) 1154 (aio-await (elfeed-tube--fetch-1 entry force-fetch)) 1155 (elfeed-tube-show entry))))) 1156 1157 (defun elfeed-tube-save (entries) 1158 "Save elfeed-tube youtube metadata for ENTRIES to the elfeed database. 1159 1160 ENTRIES is the current elfeed entry in elfeed-show buffers. In 1161 elfeed-search buffers it's the entry at point or the selected 1162 entries when the region is active." 1163 (interactive (list (elfeed-tube--get-entries))) 1164 (dolist (entry entries) 1165 (if (elfeed-tube--write-db entry) 1166 (progn (message "Wrote to elfeed-db: \"%s\"" (elfeed-entry-title entry)) 1167 (when (derived-mode-p 'elfeed-show-mode) 1168 (elfeed-show-refresh))) 1169 (message "elfeed-db already contains: \"%s\"" (elfeed-entry-title entry))))) 1170 1171 (provide 'elfeed-tube) 1172 ;;; elfeed-tube.el ends here