elfeed-tube.el (48880B)
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.15 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--entry-video-id (entry) 284 "Get Youtube video ENTRY's video-id." 285 (when-let* (((elfeed-tube--youtube-p entry)) 286 (link (elfeed-entry-link entry))) 287 (elfeed-tube--url-video-id link))) 288 289 (defsubst elfeed-tube--url-video-id (url) 290 "Get YouTube video URL's video-id." 291 (and (string-match 292 (concat 293 elfeed-tube-youtube-regexp 294 "\\(?:watch\\?v=\\)?" 295 "\\([^?&]+\\)") 296 url) 297 (match-string 1 url))) 298 299 (defsubst elfeed-tube--random-elt (collection) 300 "Random element from COLLECTION." 301 (and collection 302 (elt collection (cl-random (length collection))))) 303 304 (defsubst elfeed-tube-log (level fmt &rest objects) 305 "Log OBJECTS with FMT at LEVEL using `elfeed-log'." 306 (let ((elfeed-log-buffer-name "*elfeed-tube-log*")) 307 (apply #'elfeed-log level fmt objects) 308 nil)) 309 310 (defsubst elfeed-tube--attempt-log (attempts) 311 "Format ATTEMPTS as a string." 312 (format "(attempt %d/%d)" 313 (1+ (- elfeed-tube--max-retries 314 attempts)) 315 elfeed-tube--max-retries)) 316 317 (defsubst elfeed-tube--thumbnail-html (thumb) 318 "HTML for inserting THUMB." 319 (when (and (elfeed-tube-include-p 'thumbnail) thumb) 320 (concat "<br><img src=\"" thumb "\"></a><br><br>"))) 321 322 (defsubst elfeed-tube--timestamp (time) 323 "Format for TIME as timestamp." 324 (format "%d:%02d" (floor time 60) (mod time 60))) 325 326 (defsubst elfeed-tube--same-entry-p (entry1 entry2) 327 "Test if elfeed ENTRY1 and ENTRY2 are the same." 328 (equal (elfeed-entry-id entry1) 329 (elfeed-entry-id entry2))) 330 331 (defsubst elfeed-tube--match-captions-langs (lang el) 332 "Find caption track matching LANG in plist EL." 333 (and (or (string-match-p 334 lang 335 (plist-get el :languageCode)) 336 (string-match-p 337 lang 338 (thread-first (plist-get el :name) 339 (plist-get :simpleText)))) 340 el)) 341 342 (defsubst elfeed-tube--truncate (str) 343 "Truncate STR." 344 (truncate-string-to-width str 20)) 345 346 (defmacro elfeed-tube--with-db (db-dir &rest body) 347 "Execute BODY with DB-DIR set as the `elfeed-db-directory'." 348 (declare (indent defun)) 349 `(let ((elfeed-db-directory ,db-dir)) 350 ,@body)) 351 352 (defsubst elfeed-tube--caption-get-face (type) 353 "Get caption face for TYPE." 354 (or (alist-get type elfeed-tube-captions-faces) 355 'variable-pitch)) 356 357 ;; Data structure 358 (cl-defstruct 359 (elfeed-tube-item (:constructor elfeed-tube-item--create) 360 (:copier nil)) 361 "Struct to hold elfeed-tube metadata." 362 len thumb desc caps error) 363 364 ;; Persistence 365 (defun elfeed-tube--write-db (entry &optional data-item) 366 "Write struct DATA-ITEM to Elfeed ENTRY in `elfeed-db'." 367 (cl-assert (elfeed-entry-p entry)) 368 (when-let* ((data-item (or data-item (elfeed-tube--gethash entry)))) 369 (when (elfeed-tube-include-p 'description) 370 (setf (elfeed-entry-content-type entry) 'html) 371 (setf (elfeed-entry-content entry) 372 (when-let ((desc (elfeed-tube-item-desc data-item))) 373 (elfeed-ref desc)))) 374 (when (elfeed-tube-include-p 'duration) 375 (setf (elfeed-meta entry :duration) 376 (elfeed-tube-item-len data-item))) 377 (when (elfeed-tube-include-p 'thumbnail) 378 (setf (elfeed-meta entry :thumb) 379 (elfeed-tube-item-thumb data-item))) 380 (when (elfeed-tube-include-p 'captions) 381 (elfeed-tube--with-db elfeed-tube--captions-db-dir 382 (setf (elfeed-meta entry :caps) 383 (when-let ((caption (elfeed-tube-item-caps data-item))) 384 (elfeed-ref (prin1-to-string caption)))))) 385 (elfeed-tube-log 'info "[DB][Wrote to DB][video:%s]" 386 (elfeed-tube--truncate (elfeed-entry-title entry))) 387 t)) 388 389 (defun elfeed-tube--gethash (entry) 390 "Get hashed elfeed-tube data for ENTRY." 391 (cl-assert (elfeed-entry-p entry)) 392 (let ((video-id (elfeed-tube--entry-video-id entry))) 393 (gethash video-id elfeed-tube--info-table))) 394 395 (defun elfeed-tube--puthash (entry data-item &optional force) 396 "Cache elfeed-dube-item DATA-ITEM for ENTRY." 397 (cl-assert (elfeed-entry-p entry)) 398 (cl-assert (elfeed-tube-item-p data-item)) 399 (when-let* ((video-id (elfeed-tube--entry-video-id entry)) 400 (f (or force 401 (not (gethash video-id elfeed-tube--info-table))))) 402 ;; (elfeed-tube--message 403 ;; (format "putting %s with data %S" video-id data-item)) 404 (puthash video-id data-item elfeed-tube--info-table))) 405 406 ;; Data munging 407 (defun elfeed-tube--get-chapters (desc) 408 "Get chapter timestamps from video DESC." 409 (with-temp-buffer 410 (let ((chapters)) 411 (save-excursion (insert desc)) 412 (while (re-search-forward 413 "<a href=.*?data-jump-time=\"\\([0-9]+\\)\".*?</a>\\(?:\\s-\\|\\s)\\|-\\)+\\(.*\\)$" nil t) 414 (push (cons (match-string 1) 415 (thread-last (match-string 2) 416 (replace-regexp-in-string 417 """ "\"") 418 (replace-regexp-in-string 419 "'" "'") 420 (replace-regexp-in-string 421 "&" "&") 422 (string-trim))) 423 chapters)) 424 (nreverse chapters)))) 425 426 (defun elfeed-tube--parse-desc (api-data) 427 "Parse API-DATA for video description." 428 (let* ((length-seconds (plist-get api-data :lengthSeconds)) 429 (desc-html (plist-get api-data :descriptionHtml)) 430 (chapters (elfeed-tube--get-chapters desc-html)) 431 (desc-html (replace-regexp-in-string 432 "\n" "<br>" 433 desc-html)) 434 (thumb-alist '((large . 2) 435 (medium . 3) 436 (small . 4))) 437 (thumb-size (cdr-safe (assoc elfeed-tube-thumbnail-size 438 thumb-alist))) 439 (thumb)) 440 (when (and (elfeed-tube-include-p 'thumbnail) 441 thumb-size) 442 (setq thumb (thread-first 443 (plist-get api-data :videoThumbnails) 444 (aref thumb-size) 445 (plist-get :url)))) 446 `(:length ,length-seconds :thumb ,thumb :desc ,desc-html 447 :chaps ,chapters))) 448 449 (defun elfeed-tube--extract-captions-urls () 450 "Extract captionn URLs from Youtube HTML." 451 (catch 'parse-error 452 (if (not (search-forward "\"captions\":" nil t)) 453 (throw 'parse-error "captions section not found") 454 (delete-region (point-min) (point)) 455 (if (not (search-forward ",\"videoDetails" nil t)) 456 (throw 'parse-error "video details not found") 457 (goto-char (match-beginning 0)) 458 (delete-region (point) (point-max)) 459 (goto-char (point-min)) 460 (save-excursion 461 (while (search-forward "\n" nil t) 462 (delete-region (match-beginning 0) (match-end 0)))) 463 (condition-case nil 464 (json-parse-buffer :object-type 'plist 465 :array-type 'list) 466 (json-parse-error (throw 'parse-error "json-parse-error"))))))) 467 468 (defun elfeed-tube--postprocess-captions (text) 469 "Tweak TEXT for display in the transcript." 470 (thread-last 471 ;; (string-replace "\n" " " text) 472 (replace-regexp-in-string "\n" " " text) 473 (replace-regexp-in-string "\\bi\\b" "I") 474 (replace-regexp-in-string 475 (rx (group (syntax open-parenthesis)) 476 (one-or-more (or space punct))) 477 "\\1") 478 (replace-regexp-in-string 479 (rx (one-or-more (or space punct)) 480 (group (syntax close-parenthesis))) 481 "\\1"))) 482 483 ;; Content display 484 (defvar elfeed-tube--save-state-map 485 (let ((map (make-sparse-keymap))) 486 (define-key map [mouse-2] #'elfeed-tube-save) 487 ;; (define-key map (kbd "RET") #'elfeed-tube--browse-at-time) 488 (define-key map [follow-link] 'mouse-face) 489 map)) 490 491 (defun elfeed-tube-show (&optional intended-entry) 492 "Show extra video information in an Elfeed entry buffer. 493 494 INTENDED-ENTRY is the Elfeed entry being shown. If it is not 495 specified use the entry (if any) being displayed in the current 496 buffer." 497 (when-let* ((show-buf 498 (if intended-entry 499 (get-buffer (elfeed-show--buffer-name intended-entry)) 500 (and (elfeed-tube--youtube-p elfeed-show-entry) 501 (current-buffer)))) 502 (entry (buffer-local-value 'elfeed-show-entry show-buf)) 503 (intended-entry (or intended-entry entry))) 504 (when (elfeed-tube--same-entry-p entry intended-entry) 505 (with-current-buffer show-buf 506 (let* ((inhibit-read-only t) 507 (feed (elfeed-entry-feed elfeed-show-entry)) 508 (base (and feed (elfeed-compute-base (elfeed-feed-url feed)))) 509 (data-item (elfeed-tube--gethash entry)) 510 insertions) 511 512 (goto-char (point-max)) 513 (when (text-property-search-backward 514 'face 'message-header-name) 515 (beginning-of-line) 516 (when (looking-at "Transcript:") 517 (text-property-search-backward 518 'face 'message-header-name) 519 (beginning-of-line))) 520 521 ;; Duration 522 (if-let ((d (elfeed-tube-include-p 'duration)) 523 (duration 524 (or (and data-item (elfeed-tube-item-len data-item)) 525 (elfeed-meta entry :duration)))) 526 (elfeed-tube--insert-duration entry duration) 527 (forward-line 1)) 528 529 ;; DB Status 530 (when (and 531 elfeed-tube-save-indicator 532 (or (and data-item (elfeed-tube-item-desc data-item) 533 (not (elfeed-entry-content entry))) 534 (and data-item (elfeed-tube-item-thumb data-item) 535 (not (elfeed-meta entry :thumb))) 536 (and data-item (elfeed-tube-item-caps data-item) 537 (not (elfeed-meta entry :caps))))) 538 (let ((prop-list 539 `(face (:inherit warning :weight bold) mouse-face highlight 540 help-echo "mouse-1: save this entry to the elfeed-db" 541 keymap ,elfeed-tube--save-state-map))) 542 (if (stringp elfeed-tube-save-indicator) 543 (insert (apply #'propertize 544 elfeed-tube-save-indicator 545 prop-list) 546 "\n") 547 (save-excursion 548 (goto-char (point-min)) 549 (end-of-line) 550 (insert " " (apply #'propertize "[∗]" prop-list)))))) 551 552 ;; Thumbnail 553 (when-let ((th (elfeed-tube-include-p 'thumbnail)) 554 (thumb (or (and data-item (elfeed-tube-item-thumb data-item)) 555 (elfeed-meta entry :thumb)))) 556 (elfeed-insert-html (elfeed-tube--thumbnail-html thumb)) 557 (push 'thumb insertions)) 558 559 ;; Description 560 (delete-region (point) (point-max)) 561 (when (elfeed-tube-include-p 'description) 562 (if-let ((desc (or (and data-item (elfeed-tube-item-desc data-item)) 563 (elfeed-deref (elfeed-entry-content entry))))) 564 (progn (elfeed-insert-html (concat desc "") base) 565 (push 'desc insertions)))) 566 567 ;; Captions 568 (elfeed-tube--with-db elfeed-tube--captions-db-dir 569 (when-let* ((c (elfeed-tube-include-p 'captions)) 570 (caption 571 (or (and data-item (elfeed-tube-item-caps data-item)) 572 (and (when-let 573 ((capstr (elfeed-deref 574 (elfeed-meta entry :caps)))) 575 (condition-case nil 576 (read capstr) 577 ('error 578 (elfeed-tube-log 579 'error "[Show][Captions] DB parse error: %S" 580 (elfeed-meta entry :caps))))))))) 581 (when (not (elfeed-entry-content entry)) 582 (kill-region (point) (point-max))) 583 (elfeed-tube--insert-captions caption) 584 (push 'caps insertions))) 585 586 (if insertions 587 (delete-region (point) (point-max)) 588 (insert (propertize "\n(empty)\n" 'face 'italic)))) 589 590 (setq-local 591 imenu-prev-index-position-function #'elfeed-tube-prev-heading 592 imenu-extract-index-name-function #'elfeed-tube--line-at-point) 593 594 (goto-char (point-min)))))) 595 596 (defun elfeed-tube--insert-duration (entry duration) 597 "Insert the video DURATION for ENTRY into an Elfeed entry buffer." 598 (if (not (integerp duration)) 599 (elfeed-tube-log 600 'warn "[Duration][video:%s][Not available]" 601 (elfeed-tube--truncate (elfeed-entry-title entry))) 602 (let ((inhibit-read-only t)) 603 (beginning-of-line) 604 (if (looking-at "Duration:") 605 (delete-region (point) 606 (save-excursion (end-of-line) 607 (point))) 608 (end-of-line) 609 (insert "\n")) 610 (insert (propertize "Duration: " 'face 'message-header-name) 611 (propertize (elfeed-tube--timestamp duration) 612 'face 'message-header-other) 613 "\n") 614 t))) 615 616 (defun elfeed-tube--insert-captions (caption) 617 "Insert the video CAPTION for ENTRY into an Elfeed entry buffer." 618 (if (and (listp caption) 619 (eq (car-safe caption) 'transcript)) 620 (let ((caption-ordered 621 (cl-loop for (type (start _) text) in (cddr caption) 622 with chapters = (car-safe (cdr caption)) 623 with pstart = 0 624 for chapter = (car-safe chapters) 625 for oldtime = 0 then time 626 for time = (string-to-number (cdr start)) 627 for chap-begin-p = 628 (and chapter 629 (>= (floor time) (string-to-number (car chapter)))) 630 631 if (and 632 (or chap-begin-p 633 (< (mod (floor time) 634 elfeed-tube-captions-chunk-time) 635 (mod (floor oldtime) 636 elfeed-tube-captions-chunk-time))) 637 (> (abs (- time pstart)) 3)) 638 collect (list pstart time para) into result and 639 do (setq para nil pstart time) 640 641 if chap-begin-p 642 do (setq chapters (cdr-safe chapters)) 643 644 collect (cons time 645 (propertize 646 ;; (elfeed-tube--postprocess-captions text) 647 (replace-regexp-in-string "\n" " " text) 648 'face (elfeed-tube--caption-get-face type) 649 'type type)) 650 into para 651 finally return (nconc result (list (list pstart time para))))) 652 (inhibit-read-only t)) 653 (goto-char (point-max)) 654 (insert "\n" 655 (propertize "Transcript:" 'face 'message-header-name) 656 "\n\n") 657 (cl-loop for (start end para) in caption-ordered 658 with chapters = (car-safe (cdr caption)) 659 with vspace = (propertize " " 'face 'variable-pitch) 660 for chapter = (car-safe chapters) 661 with beg = (point) do 662 (progn 663 (when (and chapter (>= start (string-to-number (car chapter)))) 664 (insert (propertize (cdr chapter) 665 'face 666 (elfeed-tube--caption-get-face 'chapter) 667 'timestamp (string-to-number (car chapter)) 668 'mouse-face 'highlight 669 'help-echo #'elfeed-tube--caption-echo 670 'keymap elfeed-tube-captions-map 671 'type 'chapter) 672 (propertize "\n\n" 'hard t)) 673 (setq chapters (cdr chapters))) 674 (insert 675 (propertize (format "[%s] - [%s]:" 676 (elfeed-tube--timestamp start) 677 (elfeed-tube--timestamp end)) 678 'face (elfeed-tube--caption-get-face 679 'timestamp)) 680 (propertize "\n" 'hard t) 681 (string-join 682 (mapcar (lambda (tx-cons) 683 (propertize (cdr tx-cons) 684 'timestamp 685 (car tx-cons) 686 'mouse-face 687 'highlight 688 'help-echo 689 #'elfeed-tube--caption-echo 690 'keymap 691 elfeed-tube-captions-map)) 692 para) 693 vspace) 694 (propertize "\n\n" 'hard t))) 695 finally (when-let* ((w shr-width) 696 (fill-column w) 697 (use-hard-newlines t)) 698 (fill-region beg (point) nil t)))) 699 (elfeed-tube-log 'debug 700 "[Captions][video:%s][Not available]" 701 (or (and elfeed-show-entry (truncate-string-to-width 702 elfeed-show-entry 20)) 703 "")))) 704 705 (defvar elfeed-tube--captions-echo-message 706 (lambda (time) (format "mouse-2: open at %s (web browser)" time))) 707 708 (defun elfeed-tube--caption-echo (_ _ pos) 709 "Caption echo text at position POS." 710 (concat 711 (when-let ((type (get-text-property pos 'type))) 712 (when (not (eq type 'text)) 713 (format " segment: %s\n\n" (symbol-name type)))) 714 (let ((time (elfeed-tube--timestamp 715 (get-text-property pos 'timestamp)))) 716 (funcall elfeed-tube--captions-echo-message time)))) 717 718 ;; Setup 719 (defun elfeed-tube--auto-fetch (&optional entry) 720 "Fetch video information for Elfeed ENTRY and display it if possible. 721 722 If ENTRY is not specified, use the entry (if any) corresponding 723 to the current buffer." 724 (when elfeed-tube-auto-fetch-p 725 (aio-listen 726 (elfeed-tube--fetch-1 (or entry elfeed-show-entry)) 727 (lambda (fetched-p) 728 (when (funcall fetched-p) 729 (elfeed-tube-show (or entry elfeed-show-entry))))))) 730 731 (defun elfeed-tube-setup () 732 "Set up elfeed-tube. 733 734 This does the following: 735 - Enable fetching video metadata when running `elfeed-update'. 736 - Enable showing video metadata in `elfeed-show' buffers if available." 737 (add-hook 'elfeed-new-entry-hook #'elfeed-tube--auto-fetch) 738 (advice-add 'elfeed-show-entry 739 :after #'elfeed-tube--auto-fetch) 740 (advice-add elfeed-show-refresh-function 741 :after #'elfeed-tube-show) 742 t) 743 744 (defun elfeed-tube-teardown () 745 "Undo the effects of `elfeed-tube-setup'." 746 (advice-remove elfeed-show-refresh-function #'elfeed-tube-show) 747 (advice-remove 'elfeed-show-entry #'elfeed-tube--auto-fetch) 748 (remove-hook 'elfeed-new-entry-hook #'elfeed-tube--auto-fetch) 749 t) 750 751 ;; From aio-contrib.el: the workhorse 752 (defun elfeed-tube-curl-enqueue (url &rest args) 753 "Fetch URL with ARGS using Curl. 754 755 Like `elfeed-curl-enqueue' but delivered by a promise. 756 757 The result is a plist with the following keys: 758 :success -- the callback argument (t or nil) 759 :headers -- `elfeed-curl-headers' 760 :status-code -- `elfeed-curl-status-code' 761 :error-message -- `elfeed-curl-error-message' 762 :location -- `elfeed-curl-location' 763 :content -- (buffer-string)" 764 (let* ((promise (aio-promise)) 765 (cb (lambda (success) 766 (let ((result (list :success success 767 :headers elfeed-curl-headers 768 :status-code elfeed-curl-status-code 769 :error-message elfeed-curl-error-message 770 :location elfeed-curl-location 771 :content (buffer-string)))) 772 (aio-resolve promise (lambda () result)))))) 773 (prog1 promise 774 (apply #'elfeed-curl-enqueue url cb args)))) 775 776 ;; Fetchers 777 (aio-defun elfeed-tube--get-invidious-servers () 778 (let* ((instances-url (concat "https://api.invidious.io/instances.json" 779 "?pretty=1&sort_by=type,users")) 780 (result (aio-await (elfeed-tube-curl-enqueue instances-url :method "GET"))) 781 (status-code (plist-get result :status-code)) 782 (servers (plist-get result :content))) 783 (when (= status-code 200) 784 (thread-last 785 (json-parse-string servers :object-type 'plist :array-type 'list) 786 (cl-remove-if-not (lambda (s) (eq t (plist-get (cadr s) :api)))) 787 (mapcar #'car))))) 788 789 (aio-defun elfeed-tube--get-invidious-url () 790 (or elfeed-tube-invidious-url 791 (let ((servers 792 (or elfeed-tube--invidious-servers 793 (setq elfeed-tube--invidious-servers 794 (elfeed--shuffle 795 (aio-await (elfeed-tube--get-invidious-servers))))))) 796 (car servers)))) 797 798 (defsubst elfeed-tube--nrotate-invidious-servers () 799 "Rotate the list of Invidious servers in place." 800 (setq elfeed-tube--invidious-servers 801 (nconc (cdr elfeed-tube--invidious-servers) 802 (list (car elfeed-tube--invidious-servers))))) 803 804 (aio-defun elfeed-tube--fetch-captions-tracks (entry) 805 (let* ((video-id (elfeed-tube--entry-video-id entry)) 806 (url (format "https://youtube.com/watch?v=%s" video-id)) 807 (response (aio-await (elfeed-tube-curl-enqueue url :method "GET"))) 808 (status-code (plist-get response :status-code))) 809 (if-let* 810 ((s (= status-code 200)) 811 (data (with-temp-buffer 812 (save-excursion (insert (plist-get response :content))) 813 (elfeed-tube--extract-captions-urls)))) 814 ;; (message "%S" data) 815 (thread-first 816 data 817 (plist-get :playerCaptionsTracklistRenderer) 818 (plist-get :captionTracks)) 819 (elfeed-tube-log 'debug "[%s][Caption tracks]: %s" 820 url (plist-get response :error-message)) 821 (elfeed-tube-log 'warn "[Captions][video:%s]: Not available" 822 (elfeed-tube--truncate (elfeed-entry-title entry)))))) 823 824 (aio-defun elfeed-tube--fetch-captions-url (caption-plist entry) 825 (let* ((case-fold-search t) 826 (chosen-caption 827 (cl-loop 828 for lang in elfeed-tube-captions-languages 829 for pick = (cl-some 830 (lambda (el) (elfeed-tube--match-captions-langs lang el)) 831 caption-plist) 832 until pick 833 finally return pick)) 834 base-url language) 835 (cond 836 ((not caption-plist) 837 (elfeed-tube-log 838 'warn "[Captions][video:%s][No languages]" 839 (elfeed-tube--truncate (elfeed-entry-title entry)))) 840 ((not chosen-caption) 841 (elfeed-tube-log 842 'warn 843 "[Captions][video:%s][Not available in %s]" 844 (elfeed-tube--truncate (elfeed-entry-title entry)) 845 (string-join elfeed-tube-captions-languages ", "))) 846 (t (setq base-url (plist-get chosen-caption :baseUrl) 847 language (thread-first (plist-get chosen-caption :name) 848 (plist-get :simpleText))) 849 (let* ((response (aio-await (elfeed-tube-curl-enqueue base-url :method "GET"))) 850 (captions (plist-get response :content)) 851 (status-code (plist-get response :status-code))) 852 (if (= status-code 200) 853 (cons language captions) 854 (elfeed-tube-log 855 'error 856 "[Caption][video:%s][lang:%s]: %s" 857 (elfeed-tube--truncate (elfeed-entry-title entry)) 858 language 859 (plist-get response :error-message)))))))) 860 861 (defvar elfeed-tube--sblock-categories 862 '("sponsor" "intro" "outro" "selfpromo" "interaction")) 863 864 (aio-defun elfeed-tube--fetch-captions-sblock (entry) 865 (when-let* ((categories 866 (json-serialize (vconcat elfeed-tube--sblock-categories))) 867 (api-url (url-encode-url 868 (concat elfeed-tube--sblock-url 869 elfeed-tube--sblock-api-path 870 "?videoID=" (elfeed-tube--entry-video-id entry) 871 "&categories=" categories))) 872 (response (aio-await (elfeed-tube-curl-enqueue 873 api-url :method "GET"))) 874 (status-code (plist-get response :status-code)) 875 (content-json (plist-get response :content))) 876 (if (= status-code 200) 877 (condition-case nil 878 (json-parse-string content-json 879 :object-type 'plist 880 :array-type 'list) 881 (json-parse-error 882 (elfeed-tube-log 883 'error 884 "[Sponsorblock][video:%s]: JSON malformed" 885 (elfeed-tube--truncate (elfeed-entry-title entry))))) 886 (elfeed-tube-log 887 'error 888 "[Sponsorblock][video:%s]: %s" 889 (elfeed-tube--truncate (elfeed-entry-title entry)) 890 (plist-get response :error-message))))) 891 892 (aio-defun elfeed-tube--fetch-captions (entry) 893 (pcase-let* ((urls (aio-await (elfeed-tube--fetch-captions-tracks entry))) 894 (`(,language . ,xmlcaps) (aio-await (elfeed-tube--fetch-captions-url urls entry))) 895 (sblock (and elfeed-tube-captions-sblock-p 896 (aio-await (elfeed-tube--fetch-captions-sblock entry)))) 897 (parsed-caps)) 898 ;; (print (elfeed-entry-title entry) (get-buffer "*scratch*")) 899 ;; (print language (get-buffer "*scratch*")) 900 (when xmlcaps 901 (setq parsed-caps (with-temp-buffer 902 (insert xmlcaps) 903 (goto-char (point-min)) 904 (dolist (reps '(("&#39;" . "'") 905 ("&quot;" . "\"") 906 ("\n" . " ") 907 (" " . ""))) 908 (save-excursion 909 (while (search-forward (car reps) nil t) 910 (replace-match (cdr reps) nil t)))) 911 (libxml-parse-xml-region (point-min) (point-max))))) 912 (when parsed-caps 913 (when (and elfeed-tube-captions-sblock-p sblock) 914 (setq parsed-caps (elfeed-tube--sblock-captions sblock parsed-caps))) 915 (when (and elfeed-tube-captions-puntcuate-p 916 (string-match-p "auto-generated" language)) 917 (elfeed-tube--npreprocess-captions parsed-caps)) 918 parsed-caps))) 919 920 (defun elfeed-tube--npreprocess-captions (captions) 921 "Preprocess CAPTIONS." 922 (cl-loop for text-element in (cddr captions) 923 for (_ _ text) in (cddr captions) 924 do (setf (nth 2 text-element) 925 (cl-reduce 926 (lambda (accum reps) 927 (replace-regexp-in-string (car reps) (cdr reps) accum)) 928 `(("\\bi\\b" . "I") 929 (,(rx (group (syntax open-parenthesis)) 930 (one-or-more (or space punct))) 931 . "\\1") 932 (,(rx (one-or-more (or space punct)) 933 (group (syntax close-parenthesis))) 934 . "\\1")) 935 :initial-value text)) 936 finally return captions)) 937 938 (defun elfeed-tube--sblock-captions (sblock captions) 939 "Add sponsor data from SBLOCK into CAPTIONS." 940 (let ((sblock-filtered 941 (cl-loop for skip in sblock 942 for cat = (plist-get skip :category) 943 when (member cat elfeed-tube--sblock-categories) 944 collect `(:category ,cat :segment ,(plist-get skip :segment))))) 945 (cl-loop for telm in (cddr captions) 946 do (when-let 947 ((cat 948 (cl-some 949 (lambda (skip) 950 (pcase-let ((cat (intern (plist-get skip :category))) 951 (`(,beg ,end) (plist-get skip :segment)) 952 (sn (string-to-number (cdaadr telm)))) 953 (and (> sn beg) (< sn end) cat))) 954 sblock-filtered))) 955 (setf (car telm) cat)) 956 finally return captions))) 957 958 (aio-defun elfeed-tube--fetch-desc (entry &optional attempts) 959 (let* ((attempts (or attempts (1+ elfeed-tube--max-retries))) 960 (video-id (elfeed-tube--entry-video-id entry))) 961 (when (> attempts 0) 962 (if-let ((invidious-url (aio-await (elfeed-tube--get-invidious-url)))) 963 (let* ((api-url (concat 964 invidious-url 965 elfeed-tube--api-videos-path 966 video-id 967 "?fields=" 968 (string-join elfeed-tube--api-video-fields ","))) 969 (desc-log (elfeed-tube-log 970 'debug 971 "[Description][video:%s][Fetch:%s]" 972 (elfeed-tube--truncate (elfeed-entry-title entry)) 973 api-url)) 974 (api-response (aio-await (elfeed-tube-curl-enqueue 975 api-url 976 :method "GET"))) 977 (api-status (plist-get api-response :status-code)) 978 (api-data (plist-get api-response :content)) 979 (json-object-type (quote plist))) 980 (if (= api-status 200) 981 ;; Return data 982 (condition-case error 983 (prog1 984 (elfeed-tube--parse-desc 985 (json-parse-string api-data :object-type 'plist))) 986 (json-parse-error 987 (elfeed-tube-log 988 'error 989 "[Description][video:%s]: JSON malformed %s" 990 (elfeed-tube--truncate (elfeed-entry-title entry)) 991 (elfeed-tube--attempt-log attempts)) 992 (elfeed-tube--nrotate-invidious-servers) 993 (aio-await 994 (elfeed-tube--fetch-desc entry (- attempts 1))))) 995 ;; Retry #attempts times 996 (elfeed-tube-log 'error 997 "[Description][video:%s][%s]: %s %s" 998 (elfeed-tube--truncate (elfeed-entry-title entry)) 999 api-url 1000 (plist-get api-response :error-message) 1001 (elfeed-tube--attempt-log attempts)) 1002 (elfeed-tube--nrotate-invidious-servers) 1003 (aio-await 1004 (elfeed-tube--fetch-desc entry (- attempts 1))))) 1005 1006 (message 1007 "Could not find a valid Invidious url. Please customize `elfeed-tube-invidious-url'.") 1008 nil)))) 1009 1010 (aio-defun elfeed-tube--with-label (label func &rest args) 1011 (cons label (aio-await (apply func args)))) 1012 1013 (aio-defun elfeed-tube--fetch-1 (entry &optional force-fetch) 1014 (when (elfeed-tube--youtube-p entry) 1015 (let* ((fields (aio-make-select)) 1016 (cached (elfeed-tube--gethash entry)) 1017 desc thumb duration caps sblock chaps error) 1018 1019 ;; When to fetch a field: 1020 ;; - force-fetch is true: always fetch 1021 ;; - entry not cached, field not saved: fetch 1022 ;; - entry not cached but saved: don't fetch 1023 ;; - entry is cached with errors: don't fetch 1024 ;; - entry is cached without errors, field not empty: don't fetch 1025 ;; - entry is saved and field not empty: don't fetch 1026 1027 ;; Fetch description? 1028 (when (and (cl-some #'elfeed-tube-include-p 1029 '(description duration thumbnail chapters)) 1030 (or force-fetch 1031 (not (or (and cached 1032 (or (cl-intersection 1033 '(desc duration thumb chapters) 1034 (elfeed-tube-item-error cached)) 1035 (elfeed-tube-item-len cached) 1036 (elfeed-tube-item-desc cached) 1037 (elfeed-tube-item-thumb cached))) 1038 (or (elfeed-entry-content entry) 1039 (elfeed-meta entry :thumb) 1040 (elfeed-meta entry :duration)))))) 1041 (aio-select-add fields 1042 (elfeed-tube--with-label 1043 'desc #'elfeed-tube--fetch-desc entry))) 1044 1045 ;; Fetch captions? 1046 (when (and (elfeed-tube-include-p 'captions) 1047 (or force-fetch 1048 (not (or (and cached 1049 (or (elfeed-tube-item-caps cached) 1050 (memq 'caps (elfeed-tube-item-error cached)))) 1051 (elfeed-ref-p 1052 (elfeed-meta entry :caps)))))) 1053 (aio-select-add fields 1054 (elfeed-tube--with-label 1055 'caps #'elfeed-tube--fetch-captions entry)) 1056 ;; Fetch caption sblocks? 1057 (when (and nil elfeed-tube-captions-sblock-p) 1058 (aio-select-add fields 1059 (elfeed-tube--with-label 1060 'sblock #'elfeed-tube--fetch-captions-sblock entry)))) 1061 1062 ;; Record fields? 1063 (while (aio-select-promises fields) 1064 (pcase-let ((`(,label . ,data) 1065 (aio-await (aio-await (aio-select fields))))) 1066 (pcase label 1067 ('desc 1068 (if data 1069 (progn 1070 (when (elfeed-tube-include-p 'thumbnail) 1071 (setf thumb 1072 (plist-get data :thumb))) 1073 (when (elfeed-tube-include-p 'description) 1074 (setf desc 1075 (plist-get data :desc))) 1076 (when (elfeed-tube-include-p 'duration) 1077 (setf duration 1078 (plist-get data :length))) 1079 (when (elfeed-tube-include-p 'chapters) 1080 (setf chaps 1081 (plist-get data :chaps)))) 1082 (setq error (append error '(desc duration thumb))))) 1083 ('caps 1084 (if data 1085 (setf caps data) 1086 (push 'caps error))) 1087 ('sblock 1088 (and data (setf sblock data)))))) 1089 1090 ;; Add (optional) sblock and chapter info to caps 1091 (when caps 1092 (when sblock 1093 (setf caps (elfeed-tube--sblock-captions sblock caps))) 1094 (when chaps 1095 (setf (cadr caps) chaps))) 1096 1097 (if (and elfeed-tube-auto-save-p 1098 (or duration caps desc thumb)) 1099 ;; Store in db 1100 (progn (elfeed-tube--write-db 1101 entry 1102 (elfeed-tube-item--create 1103 :len duration :desc desc :thumb thumb 1104 :caps caps)) 1105 (elfeed-tube-log 1106 'info "Saved to elfeed-db: %s" 1107 (elfeed-entry-title entry))) 1108 ;; Store in session cache 1109 (when (or duration caps desc thumb error) 1110 (elfeed-tube--puthash 1111 entry 1112 (elfeed-tube-item--create 1113 :len duration :desc desc :thumb thumb 1114 :caps caps :error error) 1115 force-fetch))) 1116 ;; Return t if something was fetched 1117 (and (or duration caps desc thumb) t)))) 1118 1119 ;; Interaction 1120 (defun elfeed-tube--browse-at-time (pos) 1121 "Browse video URL at POS at current time." 1122 (interactive "d") 1123 (when-let ((time (get-text-property pos 'timestamp))) 1124 (browse-url (concat "https://youtube.com/watch?v=" 1125 (elfeed-tube--entry-video-id elfeed-show-entry) 1126 "&t=" 1127 (number-to-string (floor time)))))) 1128 1129 ;; Entry points 1130 ;;;###autoload (autoload 'elfeed-tube-fetch "elfeed-tube" "Fetch youtube metadata for Youtube video or Elfeed entry ENTRIES." t nil) 1131 (aio-defun elfeed-tube-fetch (entries &optional force-fetch) 1132 "Fetch youtube metadata for Elfeed ENTRIES. 1133 1134 In elfeed-show buffers, ENTRIES is the entry being displayed. 1135 1136 In elfeed-search buffers, ENTRIES is the entry at point, or all 1137 entries in the region when the region is active. 1138 1139 Outside of Elfeed, prompt the user for any Youtube video URL and 1140 generate an Elfeed-like summary buffer for it. 1141 1142 With optional prefix argument FORCE-FETCH, force refetching of 1143 the metadata for ENTRIES. 1144 1145 If you want to always add this metadata to the database, consider 1146 setting `elfeed-tube-auto-save-p'. To customize what kinds of 1147 metadata are fetched, customize TODO 1148 `elfeed-tube-fields'." 1149 (interactive (list (or (elfeed-tube--ensure-list (elfeed-tube--get-entries)) 1150 (read-from-minibuffer "Youtube video URL: ")) 1151 current-prefix-arg)) 1152 (if (not (listp entries)) 1153 (elfeed-tube--fake-entry entries force-fetch) 1154 (if (not elfeed-tube-fields) 1155 (message "Nothing to fetch! Customize `elfeed-tube-fields'.") 1156 (dolist (entry (elfeed-tube--ensure-list entries)) 1157 (aio-await (elfeed-tube--fetch-1 entry force-fetch)) 1158 (elfeed-tube-show entry))))) 1159 1160 (defun elfeed-tube-save (entries) 1161 "Save elfeed-tube youtube metadata for ENTRIES to the elfeed database. 1162 1163 ENTRIES is the current elfeed entry in elfeed-show buffers. In 1164 elfeed-search buffers it's the entry at point or the selected 1165 entries when the region is active." 1166 (interactive (list (elfeed-tube--get-entries))) 1167 (dolist (entry entries) 1168 (if (elfeed-tube--write-db entry) 1169 (progn (message "Wrote to elfeed-db: \"%s\"" (elfeed-entry-title entry)) 1170 (when (derived-mode-p 'elfeed-show-mode) 1171 (elfeed-show-refresh))) 1172 (message "elfeed-db already contains: \"%s\"" (elfeed-entry-title entry))))) 1173 1174 (provide 'elfeed-tube) 1175 ;;; elfeed-tube.el ends here