dotemacs

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

elfeed-tube.el (48847B)


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