dotemacs

My Emacs configuration
git clone git://git.entf.net/dotemacs
Log | Files | Refs | LICENSE

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                                   "&quot;" "\"")
    418                                  (replace-regexp-in-string
    419                                   "&#39;" "'")
    420                                  (replace-regexp-in-string
    421                                   "&amp;" "&")
    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 '(("&amp;#39;"  . "'")
    905                                           ("&amp;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