Commit Diff


commit - d14a2a71ba45a5adc3c136a7d3c1af59cd1ce3dc
commit + 3ef2dd014718873e076696886b31f5504544dd67
blob - /dev/null
blob + 8267bec6651fb2b856b99092b7acc61ee9ee450a (mode 644)
--- /dev/null
+++ elpa/aio-20200610.1904/README.md
@@ -0,0 +1,182 @@
+# aio: async/await for Emacs Lisp
+
+`aio` is to Emacs Lisp as [`asyncio`][asyncio] is to Python. This
+package builds upon Emacs 25 generators to provide functions that
+pause while they wait on asynchronous events. They do not block any
+thread while paused.
+
+Introduction: [An Async / Await Library for Emacs Lisp][post]
+
+Installation is [available through MELPA][melpa]. Since it uses the
+`record` built-in, it requires Emacs 26 or later.
+
+## Usage
+
+An async function is defined using `aio-defun` or `aio-lambda`. The
+body of such functions can use `aio-await` to pause the function and
+wait on a given promise. The function continues with the promise's
+resolved value when it's ready. The package provides a number of
+functions that return promises, and every async function returns a
+promise representing its future return value.
+
+For example:
+
+```el
+(aio-defun foo (url)
+  (aio-await (aio-sleep 3))
+  (message "Done sleeping. Now fetching %s" url)
+  (let* ((result (aio-await (aio-url-retrieve url)))
+         (contents (with-current-buffer (cdr result)
+                     (prog1 (buffer-string)
+                       (kill-buffer)))))
+    (message "Result: %s" contents)))
+```
+
+If an uncaught signal terminates an asynchronous function, that signal
+is captured by its return value promise and propagated into any
+function that awaits on that function.
+
+```el
+(aio-defun divide (a b)
+  (aio-await (aio-sleep 1))
+  (/ a b))
+
+(aio-defun divide-safe (a b)
+  (condition-case error
+      (aio-await (divide a b))
+    (arith-error :arith-error)))
+
+(aio-wait-for (divide-safe 1.0 2.0))
+;; => 0.5
+
+(aio-wait-for (divide-safe 0 0))
+;; => :arith-error
+```
+
+To convert a callback-based function into an awaitable, async-friendly
+function, create a new promise object with `aio-promise`, then
+`aio-resolve` that promise in the callback. The helper function,
+`aio-make-callback`, makes this easy.
+
+## Utility macros and functions
+
+```el
+(aio-wait-for promise)
+;; Synchronously wait for PROMISE, blocking the current thread.
+
+(aio-cancel promise)
+;; Attempt to cancel PROMISE, returning non-nil if successful.
+
+(aio-with-promise promise &rest body) [macro]
+;; Evaluate BODY and resolve PROMISE with the result.
+
+(aio-with-async &rest body) [macro]
+;; Evaluate BODY asynchronously as if it was inside `aio-lambda'.
+
+(aio-make-callback &key tag once)
+;; Return a new callback function and its first promise.
+
+(aio-chain expr) [macro]
+;; `aio-await' on EXPR and replace place EXPR with the next promise.
+```
+
+The `aio-make-callback` function is especially useful for callbacks
+that are invoked repeatedly, such as process filters and sentinels.
+The `aio-chain` macro works in conjunction.
+
+## Awaitable functions
+
+Here are some useful promise-returning — i.e. awaitable — functions
+defined by this package.
+
+```el
+(aio-sleep seconds &optional result)
+;; Return a promise that is resolved after SECONDS with RESULT.
+
+(aio-idle seconds &optional result)
+;; Return a promise that is resolved after idle SECONDS with RESULT.
+
+(aio-url-retrieve url &optional silent inhibit-cookies)
+;; Wraps `url-retrieve' in a promise.
+
+(aio-all promises)
+;; Return a promise that resolves when all PROMISES are resolved."
+```
+
+## Select API
+
+This package includes a select()-like, level-triggered API for waiting
+on multiple promises at once. Create a "select" object, add promises
+to it, and await on it. Resolved and returned promises are
+automatically removed, and the "select" object can be reused.
+
+```el
+(aio-make-select &optional promises)
+;; Create a new `aio-select' object for waiting on multiple promises.
+
+(aio-select-add select promise)
+;; Add PROMISE to the set of promises in SELECT.
+
+(aio-select-remove select promise)
+;; Remove PROMISE form the set of promises in SELECT.
+
+(aio-select-promises select)
+;; Return a list of promises in SELECT.
+
+(aio-select select)
+;; Return a promise that resolves when any promise in SELECT resolves.
+```
+
+For example, here's an implementation of sleep sort:
+
+```el
+(aio-defun sleep-sort (values)
+  (let* ((promises (mapcar (lambda (v) (aio-sleep v v)) values))
+         (select (aio-make-select promises)))
+    (cl-loop repeat (length promises)
+             for next = (aio-await (aio-select select))
+             collect (aio-await next))))
+```
+
+## Semaphore API
+
+Semaphores work just as they would as a thread synchronization
+primitive. There's an internal counter that cannot drop below zero,
+and `aio-sem-wait` is an awaitable function that may block the
+asynchronous function until another asynchronous function calls
+`aio-sem-post`. Blocked functions wait in a FIFO queue and are awoken
+in the same order that they awaited.
+
+```el
+(aio-sem init)
+;; Create a new semaphore with initial value INIT.
+
+(aio-sem-post sem)
+;; Increment the value of SEM.
+
+(aio-sem-wait sem)
+;; Decrement the value of SEM.
+```
+
+This can be used to create a work queue. For example, here's a
+configurable download queue for `url-retrieve`:
+
+```el
+(defun fetch (url-list max-parallel callback)
+  (let ((sem (aio-sem max-parallel)))
+    (dolist (url url-list)
+      (aio-with-async
+        (aio-await (aio-sem-wait sem))
+        (cl-destructuring-bind (status . buffer)
+            (aio-await (aio-url-retrieve url))
+          (aio-sem-post sem)
+          (funcall callback
+                   (with-current-buffer buffer
+                     (prog1 (buffer-string)
+                       (kill-buffer)))))))))
+```
+
+
+[asyncio]: https://docs.python.org/3/library/asyncio.html
+[melpa]: https://melpa.org/#/aio
+[post]: https://nullprogram.com/blog/2019/03/10/
blob - /dev/null
blob + 68a49daad8ff7e35068f2b7a97d643aab440eaec (mode 644)
--- /dev/null
+++ elpa/aio-20200610.1904/UNLICENSE
@@ -0,0 +1,24 @@
+This is free and unencumbered software released into the public domain.
+
+Anyone is free to copy, modify, publish, use, compile, sell, or
+distribute this software, either in source code form or as a compiled
+binary, for any purpose, commercial or non-commercial, and by any
+means.
+
+In jurisdictions that recognize copyright laws, the author or authors
+of this software dedicate any and all copyright interest in the
+software to the public domain. We make this dedication for the benefit
+of the public at large and to the detriment of our heirs and
+successors. We intend this dedication to be an overt act of
+relinquishment in perpetuity of all present and future rights to this
+software under copyright law.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
+OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
+ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+OTHER DEALINGS IN THE SOFTWARE.
+
+For more information, please refer to <http://unlicense.org/>
blob - /dev/null
blob + 86894874b32cb8070edddaef04a6a3dfb00f4f02 (mode 644)
--- /dev/null
+++ elpa/aio-20200610.1904/aio-autoloads.el
@@ -0,0 +1,28 @@
+;;; aio-autoloads.el --- automatically extracted autoloads (do not edit)   -*- lexical-binding: t -*-
+;; Generated by the `loaddefs-generate' function.
+
+;; This file is part of GNU Emacs.
+
+;;; Code:
+
+(add-to-list 'load-path (directory-file-name
+                         (or (file-name-directory #$) (car load-path))))
+
+
+
+;;; Generated autoloads from aio.el
+
+(register-definition-prefixes "aio" '("aio-"))
+
+;;; End of scraped data
+
+(provide 'aio-autoloads)
+
+;; Local Variables:
+;; version-control: never
+;; no-byte-compile: t
+;; no-update-autoloads: t
+;; coding: utf-8-emacs-unix
+;; End:
+
+;;; aio-autoloads.el ends here
blob - /dev/null
blob + cb5620e8aa7bfd6e6a416ca6d28eeade79298138 (mode 644)
--- /dev/null
+++ elpa/aio-20200610.1904/aio-pkg.el
@@ -0,0 +1,10 @@
+(define-package "aio" "20200610.1904" "async/await for Emacs Lisp"
+  '((emacs "26.1"))
+  :commit "da93523e235529fa97d6f251319d9e1d6fc24a41" :authors
+  '(("Christopher Wellons" . "wellons@nullprogram.com"))
+  :maintainer
+  '("Christopher Wellons" . "wellons@nullprogram.com")
+  :url "https://github.com/skeeto/emacs-aio")
+;; Local Variables:
+;; no-byte-compile: t
+;; End:
blob - /dev/null
blob + 5d9b3cfe89e3b74c48dccfdc34c6f8d2467d7995 (mode 644)
--- /dev/null
+++ elpa/aio-20200610.1904/aio.el
@@ -0,0 +1,470 @@
+;;; aio.el --- async/await for Emacs Lisp -*- lexical-binding: t; -*-
+
+;; This is free and unencumbered software released into the public domain.
+
+;; Author: Christopher Wellons <wellons@nullprogram.com>
+;; URL: https://github.com/skeeto/emacs-aio
+;; Version: 1.0
+;; Package-Requires: ((emacs "26.1"))
+
+;;; Commentary:
+
+;; `aio` is to Emacs Lisp as [`asyncio`][asyncio] is to Python. This
+;; package builds upon Emacs 25 generators to provide functions that
+;; pause while they wait on asynchronous events. They do not block any
+;; thread while paused.
+
+;; The main components of this package are `aio-defun' / `aio-lambda'
+;; to define async function, and `aio-await' to pause these functions
+;; while they wait on asynchronous events. When an asynchronous
+;; function is paused, the main thread is not blocked. It is no more
+;; or less powerful than callbacks, but is nicer to use.
+
+;; This is implementation is based on Emacs 25 generators, and
+;; asynchronous functions are actually iterators in disguise, operated
+;; as stackless, asymmetric coroutines.
+
+;;; Code:
+
+(require 'cl-lib)
+(require 'font-lock)
+(require 'generator)
+(require 'rx)
+
+;; Register new error types
+(define-error 'aio-cancel "Promise was canceled")
+(define-error 'aio-timeout "Timeout was reached")
+
+(defun aio-promise ()
+  "Create a new promise object."
+  (record 'aio-promise nil ()))
+
+(defsubst aio-promise-p (object)
+  "Return non-nil if OBJECT is a promise."
+  (and (eq 'aio-promise (type-of object))
+       (= 3 (length object))))
+
+(defsubst aio-result (promise)
+  "Return the result of PROMISE, or nil if it is unresolved.
+
+Promise results are wrapped in a function. The result must be
+called (e.g. `funcall') in order to retrieve the value."
+  (unless (aio-promise-p promise)
+    (signal 'wrong-type-argument (list 'aio-promise-p promise)))
+  (aref promise 1))
+
+(defun aio-listen (promise callback)
+  "Add CALLBACK to PROMISE.
+
+If the promise has already been resolved, the callback will be
+scheduled for the next event loop turn."
+  (let ((result (aio-result promise)))
+    (if result
+        (run-at-time 0 nil callback result)
+      (push callback (aref promise 2)))))
+
+(defun aio-resolve (promise value-function)
+  "Resolve this PROMISE with VALUE-FUNCTION.
+
+A promise can only be resolved once, and any further calls to
+`aio-resolve' are silently ignored. The VALUE-FUNCTION must be a
+function that takes no arguments and either returns the result
+value or rethrows a signal."
+  (unless (functionp value-function)
+    (signal 'wrong-type-argument (list 'functionp value-function)))
+  (unless (aio-result promise)
+    (let ((callbacks (nreverse (aref promise 2))))
+      (setf (aref promise 1) value-function
+            (aref promise 2) ())
+      (dolist (callback callbacks)
+        (run-at-time 0 nil callback value-function)))))
+
+(defun aio--step (iter promise yield-result)
+  "Advance ITER to the next promise.
+
+PROMISE is the return promise of the iterator, which was returned
+by the originating async function. YIELD-RESULT is the value
+function result directly from the previously yielded promise."
+  (condition-case _
+      (cl-loop for result = (iter-next iter yield-result)
+               then (iter-next iter (lambda () result))
+               until (aio-promise-p result)
+               finally (aio-listen result
+                                   (lambda (value)
+                                     (aio--step iter promise value))))
+    (iter-end-of-sequence)))
+
+(defmacro aio-with-promise (promise &rest body)
+  "Evaluate BODY and resolve PROMISE with the result.
+
+If the body signals an error, this error will be stored in the
+promise and rethrown in the promise's listeners."
+  (declare (indent defun)
+           (debug (form body)))
+  (cl-assert (eq lexical-binding t))
+  `(aio-resolve ,promise
+                (condition-case error
+                    (let ((result (progn ,@body)))
+                      (lambda () result))
+                  (error (lambda ()
+                           (signal (car error) (cdr error)))))))
+
+(defmacro aio-await (expr)
+  "If EXPR evaluates to a promise, pause until the promise is resolved.
+
+Pausing an async function does not block Emacs' main thread. If
+EXPR doesn't evaluate to a promise, the value is returned
+immediately and the function is not paused. Since async functions
+return promises, async functions can await directly on other
+async functions using this macro.
+
+This macro can only be used inside an async function, either
+`aio-lambda' or `aio-defun'."
+  `(funcall (iter-yield ,expr)))
+
+(defmacro aio-lambda (arglist &rest body)
+  "Like `lambda', but defines an async function.
+
+The body of this function may use `aio-await' to wait on
+promises. When an async function is called, it immediately
+returns a promise that will resolve to the function's return
+value, or any uncaught error signal."
+  (declare (indent defun)
+           (doc-string 3)
+           (debug (&define lambda-list lambda-doc
+                           [&optional ("interactive" interactive)]
+                           &rest sexp)))
+  (let ((args (make-symbol "args"))
+        (promise (make-symbol "promise"))
+        (split-body (macroexp-parse-body body)))
+    `(lambda (&rest ,args)
+       ,@(car split-body)
+       (let* ((,promise (aio-promise))
+              (iter (apply (iter-lambda ,arglist
+                             (aio-with-promise ,promise
+                               ,@(cdr split-body)))
+                           ,args)))
+         (prog1 ,promise
+           (aio--step iter ,promise nil))))))
+
+(defmacro aio-defun (name arglist &rest body)
+  "Like `aio-lambda' but gives the function a name like `defun'."
+  (declare (indent defun)
+           (doc-string 3)
+           (debug (&define name lambda-list &rest sexp)))
+  `(progn
+     (defalias ',name (aio-lambda ,arglist ,@body))
+     (function-put ',name 'aio-defun-p t)))
+
+(defun aio-wait-for (promise)
+  "Synchronously wait for PROMISE, blocking the current thread."
+  (while (null (aio-result promise))
+    (accept-process-output))
+  (funcall (aio-result promise)))
+
+(defun aio-cancel (promise &optional reason)
+  "Attempt to cancel PROMISE, returning non-nil if successful.
+
+All awaiters will receive an aio-cancel signal. The actual
+underlying asynchronous operation will not actually be canceled."
+  (unless (aio-result promise)
+    (aio-resolve promise (lambda () (signal 'aio-cancel reason)))))
+
+(defmacro aio-with-async (&rest body)
+  "Evaluate BODY asynchronously as if it was inside `aio-lambda'.
+
+Since BODY is evalued inside an asynchronous lambda, `aio-await'
+is available here. This macro evaluates to a promise for BODY's
+eventual result.
+
+Beware: Dynamic bindings that are lexically outside
+‘aio-with-async’ blocks have no effect.  For example,
+
+  (defvar dynamic-var nil)
+  (defun my-func ()
+    (let ((dynamic-var 123))
+      (aio-with-async dynamic-var)))
+  (let ((dynamic-var 456))
+    (aio-wait-for (my-func)))
+  ⇒ 456
+
+Other global state such as the current buffer behaves likewise."
+  (declare (indent 0)
+           (debug (&rest sexp)))
+  `(let ((promise (funcall (aio-lambda ()
+                             (aio-await (aio-sleep 0))
+                             ,@body))))
+     (prog1 promise
+       ;; The is the main feature: Force the final result to be
+       ;; realized so that errors are reported.
+       (aio-listen promise #'funcall))))
+
+(defmacro aio-chain (expr)
+  "`aio-await' on EXPR and replace place EXPR with the next promise.
+
+EXPR must be setf-able. Returns (cdr result). This macro is
+intended to be used with `aio-make-callback' in order to follow
+a chain of promise-yielding promises."
+  (let ((result (make-symbol "result")))
+    `(let ((,result (aio-await ,expr)))
+       (setf ,expr (car ,result))
+       (cdr ,result))))
+
+;; Useful promise-returning functions:
+
+(require 'url)
+
+(aio-defun aio-all (promises)
+  "Return a promise that resolves when all PROMISES are resolved."
+  (dolist (promise promises)
+    (aio-await promise)))
+
+(defun aio-catch (promise)
+  "Return a new promise that wraps PROMISE but will never signal.
+
+The promise value is a cons where the car is either :success or
+:error. For :success, the cdr will be the result value. For
+:error, the cdr will be the error data."
+  (let ((result (aio-promise)))
+    (cl-flet ((callback (value)
+                (aio-resolve result
+                             (lambda ()
+                               (condition-case error
+                                   (cons :success (funcall value))
+                                 (error (cons :error error)))))))
+      (prog1 result
+        (aio-listen promise #'callback)))))
+
+(defun aio-sleep (seconds &optional result)
+  "Create a promise that is resolved after SECONDS with RESULT.
+
+The result is a value, not a value function, and it will be
+automatically wrapped with a value function (see `aio-resolve')."
+  (let ((promise (aio-promise)))
+    (prog1 promise
+      (run-at-time seconds nil
+                   #'aio-resolve promise (lambda () result)))))
+
+(defun aio-idle (seconds &optional result)
+  "Create a promise that is resolved after idle SECONDS with RESULT.
+
+The result is a value, not a value function, and it will be
+automatically wrapped with a value function (see `aio-resolve')."
+  (let ((promise (aio-promise)))
+    (prog1 promise
+      (run-with-idle-timer seconds nil
+                           #'aio-resolve promise (lambda () result)))))
+
+(defun aio-timeout (seconds)
+  "Create a promise with a timeout error after SECONDS."
+  (let ((timeout (aio-promise)))
+    (prog1 timeout
+      (run-at-time seconds nil#'aio-resolve timeout
+                   (lambda () (signal 'aio-timeout seconds))))))
+
+(defun aio-url-retrieve (url &optional silent inhibit-cookies)
+  "Wraps `url-retrieve' in a promise.
+
+This function will never directly signal an error. Instead any
+errors will be delivered via the returned promise. The promise
+result is a cons of (status . buffer). This buffer is a clone of
+the buffer created by `url-retrieve' and should be killed by the
+caller."
+  (let ((promise (aio-promise)))
+    (prog1 promise
+      (condition-case error
+          (url-retrieve url (lambda (status)
+                              (let ((value (cons status (clone-buffer))))
+                                (aio-resolve promise (lambda () value))))
+                        silent inhibit-cookies)
+        (error (aio-resolve promise
+                            (lambda ()
+                              (signal (car error) (cdr error)))))))))
+
+(cl-defun aio-make-callback (&key tag once)
+  "Return a new callback function and its first promise.
+
+Returns a cons (callback . promise) where callback is function
+suitable for repeated invocation. This makes it useful for
+process filters and sentinels. The promise is the first promise
+to be resolved by the callback.
+
+The promise resolves to:
+  (next-promise . callback-args)
+Or when TAG is supplied:
+  (next-promise TAG . callback-args)
+Or if ONCE is non-nil:
+  callback-args
+
+The callback resolves next-promise on the next invocation. This
+creates a chain of promises representing the sequence of calls.
+Note: To avoid keeping lots of garbage in memory, avoid holding
+onto the first promise (i.e. capturing it in a closure).
+
+The `aio-chain' macro makes it easier to use these promises."
+  (let* ((promise (aio-promise))
+         (callback
+          (if once
+              (lambda (&rest args)
+                (let ((result (if tag
+                                  (cons tag args)
+                                args)))
+                  (aio-resolve promise (lambda () result))))
+            (lambda (&rest args)
+              (let* ((next-promise (aio-promise))
+                     (result (if tag
+                                 (cons next-promise (cons tag args))
+                               (cons next-promise args))))
+                (aio-resolve promise (lambda () result))
+                (setf promise next-promise))))))
+    (cons callback promise)))
+
+;; A simple little queue
+
+(defsubst aio--queue-empty-p (queue)
+  "Return non-nil if QUEUE is empty.
+An empty queue is (nil . nil)."
+  (null (caar queue)))
+
+(defsubst aio--queue-get (queue)
+  "Get the next item from QUEUE, or nil for empty."
+  (let ((head (car queue)))
+    (cond ((null head)
+           nil)
+          ((eq head (cdr queue))
+           (prog1 (car head)
+             (setf (car queue) nil
+                   (cdr queue) nil)))
+          ((prog1 (car head)
+             (setf (car queue) (cdr head)))))))
+
+(defsubst aio--queue-put (queue element)
+  "Append ELEMENT to QUEUE, returning ELEMENT."
+  (let ((new (list element)))
+    (prog1 element
+      (if (null (car queue))
+          (setf (car queue) new
+                (cdr queue) new)
+        (setf (cdr (cdr queue)) new
+              (cdr queue) new)))))
+
+;; An efficient select()-like interface for promises
+
+(defun aio-make-select (&optional promises)
+  "Create a new `aio-select' object for waiting on multiple promises."
+  (let ((select (record 'aio-select
+                        ;; Membership table
+                        (make-hash-table :test 'eq)
+                        ;; "Seen" table (avoid adding multiple callback)
+                        (make-hash-table :test 'eq :weakness 'key)
+                        ;; Queue of pending resolved promises
+                        (cons nil nil)
+                        ;; Callback to resolve select's own promise
+                        nil)))
+    (prog1 select
+      (dolist (promise promises)
+        (aio-select-add select promise)))))
+
+(defun aio-select-add (select promise)
+  "Add PROMISE to the set of promises in SELECT.
+
+SELECT is created with `aio-make-select'. It is valid to add a
+promise that was previously removed."
+  (let ((members (aref select 1))
+        (seen (aref select 2)))
+    (prog1 promise
+      (unless (gethash promise seen)
+        (setf (gethash promise seen) t
+              (gethash promise members) t)
+        (aio-listen promise
+                    (lambda (_)
+                      (when (gethash promise members)
+                        (aio--queue-put (aref select 3) promise)
+                        (remhash promise members)
+                        (let ((callback (aref select 4)))
+                          (when callback
+                            (setf (aref select 4) nil)
+                            (funcall callback))))))))))
+
+(defun aio-select-remove (select promise)
+  "Remove PROMISE form the set of promises in SELECT.
+
+SELECT is created with `aio-make-select'."
+  (remhash promise (aref select 1)))
+
+(defun aio-select-promises (select)
+  "Return a list of promises in SELECT.
+
+SELECT is created with `aio-make-select'."
+  (cl-loop for key being the hash-keys of (aref select 1)
+           collect key))
+
+(defun aio-select (select)
+  "Return a promise that resolves when any promise in SELECT resolves.
+
+SELECT is created with `aio-make-select'. This function is
+level-triggered: if a promise in SELECT is already resolved, it
+returns immediately with that promise. Promises returned by
+`aio-select' are automatically removed from SELECT. Use this
+function to repeatedly wait on a set of promises.
+
+Note: The promise returned by this function resolves to another
+promise, not that promise's result. You will need to `aio-await'
+on it, or use `aio-result'."
+  (let* ((result (aio-promise))
+         (callback (lambda ()
+                     (let ((promise (aio--queue-get (aref select 3))))
+                       (aio-resolve result (lambda () promise))))))
+    (prog1 result
+      (if (aio--queue-empty-p (aref select 3))
+          (setf (aref select 4) callback)
+        (funcall callback)))))
+
+;; Semaphores
+
+(defun aio-sem (init)
+  "Create a new semaphore with initial value INIT."
+  (record 'aio-sem
+          ;; Semaphore value
+          init
+          ;; Queue of waiting async functions
+          (cons nil nil)))
+
+(defun aio-sem-post (sem)
+  "Increment the value of SEM.
+
+If asynchronous functions are awaiting on SEM, then one will be
+woken up. This function is not awaitable."
+  (when (<= (cl-incf (aref sem 1)) 0)
+    (let ((waiting (aio--queue-get (aref sem 2))))
+      (when waiting
+        (aio-resolve waiting (lambda () nil))))))
+
+(defun aio-sem-wait (sem)
+  "Decrement the value of SEM.
+
+If SEM is at zero, returns a promise that will resolve when
+another asynchronous function uses `aio-sem-post'."
+  (when (< (cl-decf (aref sem 1)) 0)
+    (aio--queue-put (aref sem 2) (aio-promise))))
+
+;; `emacs-lisp-mode' font lock
+
+(font-lock-add-keywords
+ 'emacs-lisp-mode
+ `((,(rx "(aio-defun" (+ blank)
+         (group (+ (or (syntax word) (syntax symbol)))))
+    1 'font-lock-function-name-face)))
+
+(add-hook 'help-fns-describe-function-functions #'aio-describe-function)
+
+(defun aio-describe-function (function)
+  "Insert whether FUNCTION is an asynchronous function.
+This function is added to ‘help-fns-describe-function-functions’."
+  (when (function-get function 'aio-defun-p)
+    (insert "  This function is asynchronous; it returns "
+            "an ‘aio-promise’ object.\n")))
+
+(provide 'aio)
+
+;;; aio.el ends here
blob - /dev/null
blob + ce227aab9f499ec25ae27b26fda05870afde4dda (mode 644)
--- /dev/null
+++ elpa/elfeed-tube-20220703.2128/elfeed-tube-autoloads.el
@@ -0,0 +1,35 @@
+;;; elfeed-tube-autoloads.el --- automatically extracted autoloads (do not edit)   -*- lexical-binding: t -*-
+;; Generated by the `loaddefs-generate' function.
+
+;; This file is part of GNU Emacs.
+
+;;; Code:
+
+(add-to-list 'load-path (directory-file-name
+                         (or (file-name-directory #$) (car load-path))))
+
+
+
+;;; Generated autoloads from elfeed-tube.el
+
+ (autoload 'elfeed-tube-fetch "elfeed-tube" "Fetch youtube metadata for Youtube video or Elfeed entry ENTRIES." t nil)
+(register-definition-prefixes "elfeed-tube" '("elfeed-tube-"))
+
+
+;;; Generated autoloads from elfeed-tube-utils.el
+
+ (autoload 'elfeed-tube-add-feeds "elfeed-tube-utils" "Add youtube feeds to the Elfeed database by QUERIES." t nil)
+(register-definition-prefixes "elfeed-tube-utils" '("elfeed-tube-"))
+
+;;; End of scraped data
+
+(provide 'elfeed-tube-autoloads)
+
+;; Local Variables:
+;; version-control: never
+;; no-byte-compile: t
+;; no-update-autoloads: t
+;; coding: utf-8-emacs-unix
+;; End:
+
+;;; elfeed-tube-autoloads.el ends here
blob - /dev/null
blob + 5531df51540da914657863e382729a13c18bf065 (mode 644)
--- /dev/null
+++ elpa/elfeed-tube-20220703.2128/elfeed-tube-pkg.el
@@ -0,0 +1,14 @@
+(define-package "elfeed-tube" "20220703.2128" "YouTube integration for Elfeed"
+  '((emacs "27.1")
+    (elfeed "3.4.1")
+    (aio "1.0"))
+  :commit "5817c91f5b3b7159965aa73839d2a0a08fd952bd" :authors
+  '(("Karthik Chikmagalur" . "karthik.chikmagalur@gmail.com"))
+  :maintainer
+  '("Karthik Chikmagalur" . "karthik.chikmagalur@gmail.com")
+  :keywords
+  '("news" "hypermedia" "convenience")
+  :url "https://github.com/karthink/elfeed-tube")
+;; Local Variables:
+;; no-byte-compile: t
+;; End:
blob - /dev/null
blob + d99d60527f8211662973c69e4c223df32223973d (mode 644)
--- /dev/null
+++ elpa/elfeed-tube-20220703.2128/elfeed-tube-utils.el
@@ -0,0 +1,484 @@
+;;; elfeed-tube-utils.el --- utilities for elfeed-tube  -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2022  Karthik Chikmagalur
+
+;; Author: Karthik Chikmagalur <karthikchikmagalur@gmail.com>
+;; Keywords: multimedia, convenience
+
+;; SPDX-License-Identifier: UNLICENSE
+
+;; This file is NOT part of GNU Emacs.
+
+;;; Commentary:
+;;
+;; Utilities for Elfeed Tube.
+;;
+;;; Code:
+(require 'rx)
+(require 'aio)
+(require 'elfeed)
+
+(declare-function elfeed-tube--with-label "elfeed-tube")
+(declare-function elfeed-tube--fetch-1 "elfeed-tube")
+(declare-function elfeed-tube-show "elfeed-tube")
+(declare-function elfeed-tube-curl-enqueue "elfeed-tube")
+(declare-function elfeed-tube--attempt-log "elfeed-tube")
+(declare-function elfeed-tube-log "elfeed-tube")
+(declare-function elfeed-tube--get-invidious-url "elfeed-tube")
+(declare-function elfeed-tube--nrotate-invidious-servers "elfeed-tube")
+
+(defvar elfeed-tube-youtube-regexp)
+(defvar elfeed-tube--api-videos-path)
+(defvar elfeed-tube--max-retries)
+
+(defsubst elfeed-tube--ensure-list (var)
+  "Ensure VAR is a list."
+  (if (listp var) var (list var)))
+
+(cl-defstruct (elfeed-tube-channel (:constructor elfeed-tube-channel-create)
+                                   (:copier nil))
+  "Struct to hold youtube channel information."
+  query author url feed)
+
+;;;###autoload (autoload 'elfeed-tube-add-feeds "elfeed-tube-utils" "Add youtube feeds to the Elfeed database by QUERIES." t nil)
+(aio-defun elfeed-tube-add-feeds (queries &optional _)
+  "Add youtube feeds to the Elfeed database by QUERIES.
+
+Each query can be a video, playlist or channel URL and the
+corresponding channel feed will be selected. It can also be a
+search term and the best match will be found. You will be asked
+to finalize the results before committing them to Elfeed.
+
+When called interactively, multiple queries can be provided by
+separating them with the `crm-separator', typically
+comma (\",\"). Search terms cannot include the `crm-separator'.
+
+When called noninteractively, it accepts a query or a list of
+queries."
+  (interactive
+   (list (completing-read-multiple
+          "Video, Channel, Playlist URLs or search queries: "
+          #'ignore)
+         current-prefix-arg))
+  (message "Finding RSS feeds, hold tight!")
+  (let ((channels (aio-await (elfeed-tube-add--get-channels queries))))
+    (elfeed-tube-add--display-channels channels)))
+
+(defsubst elfeed-tube--video-p (cand)
+  "Check if CAND is a Youtube video URL."
+  (string-match
+   (concat
+    elfeed-tube-youtube-regexp
+    (rx (zero-or-one "watch?v=")
+        (group (1+ (not "&")))))
+   cand))
+
+(defsubst elfeed-tube--playlist-p (cand)
+  "Check if CAND is a Youtube playlist URL."
+  (string-match
+   (concat
+    elfeed-tube-youtube-regexp
+    "playlist\\?list="
+    (rx (group (1+ (not "&")))))
+   cand))
+
+(defsubst elfeed-tube--channel-p (cand)
+  "Check if CAND is a Youtube channel URL."
+  (string-match
+   (concat
+    elfeed-tube-youtube-regexp
+    (rx "channel/"
+        (group (1+ (not "&")))))
+   cand))
+
+(aio-defun elfeed-tube-add--get-channels (queries)
+  (let* ((fetches (aio-make-select))
+         (queries (elfeed-tube--ensure-list queries))
+         (playlist-base-url
+          "https://www.youtube.com/feeds/videos.xml?playlist_id=")
+         (channel-base-url
+          "https://www.youtube.com/feeds/videos.xml?channel_id=")
+         channels)
+
+    ;; Add all promises to fetches, an aio-select
+    (dolist (q queries channels)
+      (setq q (string-trim q))
+      (cond
+       ((elfeed-tube--channel-p q)
+        (let* ((chan-id (match-string 1 q))
+               (api-url (concat (aio-await (elfeed-tube--get-invidious-url))
+                                "/api/v1/channels/"
+                                chan-id
+                                "?fields=author,authorUrl"))
+               (feed (concat channel-base-url chan-id)))
+          (aio-select-add fetches
+                          (elfeed-tube--with-label
+                           `(:type channel :feed ,feed :query ,q)
+                           #'elfeed-tube--aio-fetch
+                           api-url #'elfeed-tube--nrotate-invidious-servers))))
+        
+       ((string-match
+         (concat elfeed-tube-youtube-regexp "c/" "\\([^?&]+\\)") q)
+        ;; Interpret channel url as search query
+        (let* ((search-url "/api/v1/search")
+               (api-url (concat (aio-await (elfeed-tube--get-invidious-url))
+                                search-url
+                                "?q=" (url-hexify-string (match-string 1 q))
+                                "&type=channel&page=1")))
+          (aio-select-add fetches
+                          (elfeed-tube--with-label
+                           `(:type search :query ,q)
+                           #'elfeed-tube--aio-fetch
+                           api-url #'elfeed-tube--nrotate-invidious-servers))))
+
+       ((elfeed-tube--playlist-p q)
+        (let* ((playlist-id (match-string 1 q))
+               (api-url (concat (aio-await (elfeed-tube--get-invidious-url))
+                                "/api/v1/playlists/"
+                                playlist-id
+                                "?fields=title,author"))
+               (feed (concat playlist-base-url playlist-id)))
+            (aio-select-add fetches
+                            (elfeed-tube--with-label
+                             `(:type playlist :feed ,feed :query ,q)
+                             #'elfeed-tube--aio-fetch
+                             api-url #'elfeed-tube--nrotate-invidious-servers))))
+       
+       ((elfeed-tube--video-p q)
+        (if-let* ((video-id (match-string 1 q))
+                  (videos-url "/api/v1/videos/")
+                  (api-url (concat (aio-await (elfeed-tube--get-invidious-url))
+                                   videos-url
+                                   video-id
+                                   "?fields=author,authorUrl,authorId")))
+            (aio-select-add fetches
+                            (elfeed-tube--with-label
+                             `(:type video :query ,q)
+                             #'elfeed-tube--aio-fetch
+                             api-url #'elfeed-tube--nrotate-invidious-servers))
+          (push (elfeed-tube-channel-create :query q)
+                channels)))
+       
+       (t ;interpret as search query
+        (let* ((search-url "/api/v1/search")
+               (api-url (concat (aio-await (elfeed-tube--get-invidious-url))
+                                search-url
+                                "?q=" (url-hexify-string q)
+                                "&type=channel&page=1")))
+            (aio-select-add fetches
+                            (elfeed-tube--with-label
+                             `(:type search :query ,q)
+                             #'elfeed-tube--aio-fetch
+                             api-url #'elfeed-tube--nrotate-invidious-servers))))))
+    
+    ;; Resolve all promises in the aio-select
+    (while (aio-select-promises fetches)
+      (pcase-let* ((`(,label . ,data)
+                    (aio-await (aio-await (aio-select fetches))))
+                   (q (plist-get label :query))
+                   (feed (plist-get label :feed)))
+        (pcase (plist-get label :type)
+          ('channel
+           (if-let ((author (plist-get data :author))
+                    (author-url (plist-get data :authorUrl)))
+               (push (elfeed-tube-channel-create
+                      :query q :author author
+                      :url  q
+                      :feed feed)
+                     channels)
+             (push (elfeed-tube-channel-create :query q :feed feed)
+                   channels)))
+          
+          ('playlist
+           (if-let ((title (plist-get data :title))
+                    (author (plist-get data :author)))
+               (push (elfeed-tube-channel-create
+                      :query q :author title :url q
+                      :feed feed)
+                     channels)
+             (push (elfeed-tube-channel-create
+                    :query q :url q
+                    :feed feed)
+                   channels)))
+          ('video
+           (if-let* ((author (plist-get data :author))
+                     (author-id (plist-get data :authorId))
+                     (author-url (plist-get data :authorUrl))
+                     (feed (concat channel-base-url author-id)))
+               (push (elfeed-tube-channel-create
+                      :query q :author author
+                      :url (concat "https://www.youtube.com" author-url)
+                      :feed feed)
+                     channels)
+             (push (elfeed-tube-channel-create :query (plist-get label :query))
+                   channels)))
+          ('search
+           (if-let* ((chan-1 (and (> (length data) 0)
+                                  (aref data 0)))
+                     (author (plist-get chan-1 :author))
+                     (author-id (plist-get chan-1 :authorId))
+                     (author-url (plist-get chan-1 :authorUrl))
+                     (feed (concat channel-base-url author-id)))
+               (push (elfeed-tube-channel-create
+                      :query q :author author
+                      :url (concat "https://www.youtube.com" author-url)
+                      :feed feed)
+                     channels)
+             (push (elfeed-tube-channel-create :query q)
+                   channels))))))
+    
+    (nreverse channels)))
+
+(defun elfeed-tube-add--display-channels (channels)
+  "Summarize found Youtube channel feeds CHANNELS."
+  (let ((buffer (get-buffer-create "*Elfeed-Tube Channels*"))
+        (notfound (propertize "Not found!" 'face 'error)))
+    (with-current-buffer buffer
+      (let ((inhibit-read-only t)) (erase-buffer))
+      (elfeed-tube-channels-mode)
+      (setq
+       tabulated-list-entries
+       (cl-loop for channel in channels
+                for n upfrom 1
+                for author = (if-let ((url (elfeed-tube-channel-url channel)))
+                                 (list (elfeed-tube-channel-author channel)
+                                       'mouse-face 'highlight
+                                       'action
+                                       #'elfeed-tube-add--visit-channel
+                                       'follow-link t
+                                       'help-echo (elfeed-tube-channel-url channel))
+                                 notfound)
+                for feed = (or (elfeed-tube-channel-feed channel) notfound)
+                collect
+                `(,n
+                  [,author
+                   ,(replace-regexp-in-string
+                     elfeed-tube-youtube-regexp ""
+                     (elfeed-tube-channel-query channel))
+                   ,feed])))
+      (tabulated-list-init-header)
+      (tabulated-list-print)
+      (goto-address-mode 1)
+      
+      (goto-char (point-max))
+      
+      (let ((inhibit-read-only t)
+            (fails (cl-reduce
+                    (lambda (sum ch)
+                      (+ sum
+                         (or (and (elfeed-tube-channel-feed ch) 0) 1)))
+                    channels :initial-value 0))
+            (continue (propertize "C-c C-c" 'face 'help-key-binding))
+            (continue-extra (propertize "C-u C-c C-c" 'face 'help-key-binding))
+            (cancel-q (propertize "q" 'face 'help-key-binding))
+            (cancel   (propertize "C-c C-k" 'face 'help-key-binding))
+            (copy     (propertize "C-c C-w" 'face 'help-key-binding)))
+        
+        (let ((inhibit-message t))
+          (toggle-truncate-lines 1))
+        (insert "\n")
+        (when (> fails 0)
+          (insert (propertize
+                   (format "%d queries could not be resolved.\n\n" fails)
+                   'face 'error)
+                  "     " continue ": Add found feeds to the Elfeed database, ignoring the failures.\n"
+                  " " continue-extra ": Add found feeds, fetch entries from them and open Elfeed.\n"))
+        (when (= fails 0)
+          (insert
+           (propertize
+            "All queries resolved successfully.\n\n"
+            'face 'success)
+           "     " continue ": Add all feeds to the Elfeed database.\n"
+           " " continue-extra ": Add all feeds, fetch entries from them and open Elfeed.\n"
+           "     " copy ": Copy the list of feed URLs as a list\n"))
+        (insert "\n" cancel-q " or " cancel ": Quit and cancel this operation."))
+      
+      (goto-char (point-min))
+      
+      (funcall
+       (if (bound-and-true-p demo-mode)
+           #'switch-to-buffer
+         #'display-buffer)
+       buffer))))
+
+(defun elfeed-tube-add--visit-channel (button)
+  "Activate BUTTON."
+  (browse-url (button-get button 'help-echo)))
+
+;; (elfeed-tube-add--display-channels my-channels)
+
+(defun elfeed-tube-add--confirm (&optional arg)
+  "Confirm the addition of visible Youtube feeds to the Elfeed database.
+
+With optional prefix argument ARG, update these feeds and open Elfeed
+afterwards."
+  (interactive "P")
+  (cl-assert (derived-mode-p 'elfeed-tube-channels-mode))
+  (let* ((channels tabulated-list-entries))
+    (let ((inhibit-message t))
+      (cl-loop for channel in channels
+               for (_ _ feed) = (append (cadr channel) nil)
+               do (elfeed-add-feed feed :save t)))
+    (message "Added to elfeed-feeds.")
+    (when arg (elfeed))))
+
+(define-derived-mode elfeed-tube-channels-mode tabulated-list-mode
+  "Elfeed Tube Channels"
+  (setq tabulated-list-use-header-line t ; default to no header
+        header-line-format nil
+        ;; tabulated-list--header-string nil
+        tabulated-list-format
+        '[("Channel" 22 t)
+          ("Query" 32 t)
+          ("Feed URL" 30 nil)]))
+
+(defvar elfeed-tube-channels-mode-map
+  (let ((map (make-sparse-keymap)))
+    (define-key map (kbd "C-c C-k") #'kill-buffer)
+    (define-key map (kbd "C-c C-c") #'elfeed-tube-add--confirm)
+    (define-key map (kbd "C-c C-w") #'elfeed-tube-add--copy)
+    map))
+
+(defun elfeed-tube-add--copy ()
+  "Copy visible Youtube feeds to the kill ring as a list.
+
+With optional prefix argument ARG, update these feeds and open Elfeed
+afterwards."
+  (interactive)
+  (cl-assert (derived-mode-p 'elfeed-tube-channels-mode))
+  (let* ((channels tabulated-list-entries))
+    (cl-loop for channel in channels
+             for (_ _ feed) = (append (cadr channel) nil)
+             collect feed into feeds
+             finally (kill-new (prin1-to-string feeds)))
+    (message "Feed URLs saved to kill-ring.")))
+
+(aio-defun elfeed-tube--aio-fetch (url &optional next desc attempts)
+  "Fetch URL asynchronously using `elfeed-curl-retrieve'.
+
+If successful (HTTP 200), return the JSON-parsed result as a
+plist.
+
+Otherwise, call the function NEXT (with no arguments) and try
+ATTEMPTS more times. Return nil if all attempts fail. DESC is a
+description string to print to the elfeed-tube log allong with
+any other error messages.
+
+This function returns a promise."
+  (let ((attempts (or attempts (1+ elfeed-tube--max-retries))))
+    (when (> attempts 0)
+      (let* ((response
+              (aio-await (elfeed-tube-curl-enqueue url :method "GET")))
+             (content (plist-get response :content))
+             (status (plist-get response :status-code))
+             (error-msg (plist-get response :error-message)))
+        (cond
+         ((= status 200)
+          (condition-case nil
+              (json-parse-string content :object-type 'plist)
+            ((json-parse-error error)
+             (elfeed-tube-log 'error "[Search] JSON malformed (%s)"
+                              (elfeed-tube--attempt-log attempts))
+             (and (functionp next) (funcall next))
+             (aio-await
+              (elfeed-tube--aio-fetch url next desc (1- attempts))))))
+         (t (elfeed-tube-log 'error "[Search][%s]: %s (%s)" error-msg url
+                             (elfeed-tube--attempt-log attempts))
+            (and (functionp next) (funcall next))
+            (aio-await
+             (elfeed-tube--aio-fetch url next desc (1- attempts)))))))))
+
+(aio-defun elfeed-tube--fake-entry (url &optional force-fetch)
+  (string-match (concat elfeed-tube-youtube-regexp
+                        (rx (zero-or-one "watch?v=")
+                            (group (1+ (not (or "&" "?"))))))
+                url)
+  (if-let ((video-id (match-string 1 url)))
+      (progn
+        (message "Creating a video summary...")
+        (cl-letf* ((elfeed-show-unique-buffers t)
+                   (elfeed-show-entry-switch #'display-buffer)
+                   (elfeed-tube-save-indicator nil)
+                   (elfeed-tube-auto-save-p nil)
+                   (api-data (aio-await
+                              (elfeed-tube--aio-fetch
+                               (concat (aio-await (elfeed-tube--get-invidious-url))
+                                       elfeed-tube--api-videos-path
+                                       video-id
+                                       "?fields="
+                                       ;; "videoThumbnails,descriptionHtml,lengthSeconds,"
+                                       "title,author,authorUrl,published")
+                               #'elfeed-tube--nrotate-invidious-servers)))
+                   (feed-id (concat "https://www.youtube.com/feeds/videos.xml?channel_id="
+                                    (nth 1 (split-string (plist-get api-data :authorUrl)
+                                                         "/" t))))
+                   (author `((:name ,(plist-get api-data :author)
+                                    :uri ,feed-id)))
+                   (entry
+                    (elfeed-entry--create
+                     :link url
+                     :title (plist-get api-data :title)
+                     :id `("www.youtube.com" . ,(concat "yt:video:" video-id))
+                     :date (plist-get api-data :published)
+                     :tags '(youtube)
+                     :content-type 'html
+                     :meta `(:authors ,author)
+                     :feed-id feed-id))
+                   ((symbol-function 'elfeed-entry-feed)
+                    (lambda (_)
+                      (elfeed-feed--create
+                       :id feed-id
+                       :url feed-id
+                       :title (plist-get api-data :author)
+                       :author author))))
+          (aio-await (elfeed-tube--fetch-1 entry force-fetch))
+          (with-selected-window (elfeed-show-entry entry)
+            (message "Summary created for video: \"%s\""
+                     (elfeed-entry-title entry))
+            (setq-local elfeed-show-refresh-function
+                        (lambda () (interactive)
+                          (elfeed-tube-show))
+                        elfeed-tube-save-indicator nil))))
+    (message "Not a youtube video URL, aborting.")))
+
+(defsubst elfeed-tube--line-at-point ()
+  "Get line around point."
+  (buffer-substring (line-beginning-position) (line-end-position)))
+
+(defun elfeed-tube-next-heading (&optional arg)
+  "Jump to the next heading in an Elfeed entry.
+
+With numeric prefix argument ARG, jump forward that many times.
+If ARG is negative, jump backwards instead."
+  (interactive "p")
+  (unless arg (setq arg 1))
+  (catch 'return
+    (dotimes (_ (abs arg))
+      (when (> arg 0) (end-of-line))
+      (if-let ((match
+                (funcall (if (> arg 0)
+                             #'text-property-search-forward
+                           #'text-property-search-backward)
+                         'face `(shr-h1 shr-h2 shr-h3
+                                        message-header-name elfeed-tube-chapter-face)
+                         (lambda (tags face)
+                           (cl-loop for x in (if (consp face) face (list face))
+                                    thereis (memq x tags)))
+                         t)))
+          (goto-char
+           (if (> arg 0) (prop-match-beginning match) (prop-match-end match)))
+        (throw 'return nil))
+      (when (< arg 0) (beginning-of-line)))
+    (beginning-of-line)
+    (point)))
+
+(defun elfeed-tube-prev-heading (&optional arg)
+  "Jump to the previous heading in an Elfeed entry.
+
+With numeric prefix argument ARG, jump backward that many times.
+If ARG is negative, jump forward instead."
+  (interactive "p")
+  (elfeed-tube-next-heading (- (or arg 1))))
+
+(provide 'elfeed-tube-utils)
+;;; elfeed-tube-utils.el ends here
blob - /dev/null
blob + b0962993c59df51586d0bf3d748a423e6e277da7 (mode 644)
--- /dev/null
+++ elpa/elfeed-tube-20220703.2128/elfeed-tube.el
@@ -0,0 +1,1172 @@
+;;; elfeed-tube.el --- YouTube integration for Elfeed  -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2022  Karthik Chikmagalur
+
+;; Author: Karthik Chikmagalur <karthik.chikmagalur@gmail.com>
+;; Version: 0.10
+;; Package-Requires: ((emacs "27.1") (elfeed "3.4.1") (aio "1.0"))
+;; Keywords: news, hypermedia, convenience
+;; URL: https://github.com/karthink/elfeed-tube
+
+;; SPDX-License-Identifier: UNLICENSE
+
+;; This file is NOT part of GNU Emacs.
+
+;;; Commentary:
+;;
+;; Elfeed Tube is an extension for Elfeed, the feed reader for Emacs, that
+;; enhances your Youtube RSS feed subscriptions.
+;;
+;; Typically Youtube RSS feeds contain only the title and author of each video.
+;; Elfeed Tube adds video descriptions, thumbnails, durations, chapters and
+;; "live" transcrips to video entries. See
+;; https://github.com/karthink/elfeed-tube for demos. This information can
+;; optionally be added to your entry in your Elfeed database.
+;;
+;; The displayed transcripts and chapter headings are time-aware, so you can
+;; click on any transcript segment to visit the video at that time (in a browser
+;; or your video player if you also have youtube-dl). A companion package,
+;; `elfeed-tube-mpv', provides complete mpv (video player) integration with the
+;; transcript, including video seeking through the transcript and following
+;; along with the video in Emacs.
+;;
+;; To use this package,
+;;
+;; (i) Subscribe to Youtube channel or playlist feeds in Elfeed. You can use the
+;; helper function `elfeed-tube-add-feeds' provided by this package to search for
+;; Youtube channels by URLs or search queries.
+;;
+;; (ii) Place in your init file the following:
+;;
+;; (require 'elfeed-tube)
+;; (elfeed-tube-setup)
+;;
+;; (iii) Use Elfeed as normal, typically with `elfeed'. Your Youtube feed
+;; entries should be fully populated.
+;;
+;; You can also call `elfeed-tube-fetch' in an Elfeed buffer to manually
+;; populate an entry, or obtain an Elfeed entry-like summary for ANY youtube
+;; video (no subscription needed) by manually calling `elfeed-tube-fetch' from
+;; outside Elfeed.
+;; 
+;; User options:
+;;
+;; There are three options of note:
+;;
+;; `elfeed-tube-fields': Customize this to set the kinds of metadata you want
+;; added to Elfeed's Youtube entries. You can selectively turn on/off
+;; thumbnails, transcripts etc.
+;;
+;; `elfeed-tube-auto-save-p': Set this boolean to save fetched Youtube metadata
+;; to your Elfeed database, i.e. to persist the data on disk for all entries.
+;;
+;; `elfeed-tube-auto-fetch-p': Unset this boolean to turn off fetching metadata.
+;; You can then call `elfeed-tube-fetch' to manually fetch data for specific
+;; feed entries.
+;;
+;; See the customization group `elfeed-tube' for more options. See the README
+;; for more information.
+;; 
+;;; Code:
+(require 'elfeed)
+(eval-when-compile
+  (require 'cl-lib))
+(require 'subr-x)
+(require 'rx)
+(require 'aio)
+
+(require 'elfeed-tube-utils)
+
+;; Customizatiion options
+(defgroup elfeed-tube nil
+  "Elfeed-tube: View youtube details in Elfeed."
+  :group 'elfeed
+  :prefix "elfeed-tube-")
+
+(defcustom elfeed-tube-fields
+  '(duration thumbnail description captions chapters)
+  "Metadata fields to fetch for youtube entries in Elfeed.
+
+This is a list of symbols. The ordering is not relevant.
+
+The choices are
+- duration for video length,
+- thumbnail for video thumbnail,
+- description for video description,
+- captions for video transcript,
+- comments for top video comments. (NOT YET IMPLEMENTED)
+
+Other symbols are ignored.
+
+To set the thumbnail size, see `elfeed-tube-thumbnail-size'.
+To set caption language(s), see `elfeed-tube-captions-languages'."
+  :group 'elfeed-tube
+  :type '(repeat (choice (const duration :tag "Duration")
+                         (const thumbnail :tag "Thumbnail")
+                         (const description :tag "Description")
+                         (const captions :tag "Transcript")))) ;TODO
+
+(defcustom elfeed-tube-thumbnail-size 'small
+  "Video thumbnail size to show in the Elfeed buffer.
+
+This is a symbol. Choices are large, medium and small. Setting
+this to nil to disable showing thumbnails, but customize
+`elfeed-tube-fields' for that instead."
+  :group 'elfeed-tube
+  :type '(choice (const :tag "No thumbnails" nil)
+                 (const :tag "Large thumbnails" large)
+                 (const :tag "Medium thumbnails" medium)
+                 (const :tag "Small thumbnails" small)))
+
+(defcustom elfeed-tube-invidious-url nil
+  "Invidious URL to use for retrieving data.
+
+Setting this is optional: If left unset, elfeed-tube will locate
+and use an Invidious URL at random. This should be set to a
+string, for example \"https://invidio.us\"."
+  :group 'elfeed-tube
+  :type '(choice (string :tag "Custom URL")
+                 (const :tag "Disabled (Auto)" nil)))
+
+(defcustom elfeed-tube-youtube-regexp
+  (rx bol
+      (zero-or-one (or "http://" "https://"))
+      (zero-or-one "www.")
+      (or "youtube.com/" "youtu.be/"))
+  "Regular expression to match Elfeed entry URLss against.
+
+Only entries that match this regexp will be handled by
+elfeed-tube when fetching information."
+  :group 'elfeed-tube
+  :type 'string)
+
+(defcustom elfeed-tube-captions-languages
+  '("en" "english" "english (auto generated)")
+  "Caption language priority for elfeed-tube captions.
+
+Captions in the first available langauge in this list will be
+fetched. Each entry (string) in the list can be a language code
+or a language name (case-insensitive, \"english\"):
+
+- \"en\" for English
+- \"tr\" for Turkish
+- \"ar\" for Arabic
+- \"de\" for German
+- \"pt-BR\" for Portugese (Brazil), etc
+
+Example:
+ (\"tr\" \"es\" \"arabic\" \"english\" \"english (auto generated)\")
+
+NOTE: Language codes are safer to use. Language full names differ
+across regions. For example, \"english\" would be spelled
+\"englisch\" if you are in Germany."
+  :group 'elfeed-tube
+  :type '(repeat string))
+
+(defcustom elfeed-tube-save-indicator "[*NOT SAVED*]"
+  "Indicator to show in Elfeed entry buffers that have unsaved metadata.
+
+This can be set to a string, which will be displayed below the
+headers as a button. Activating this button saves the metadata to
+the Elfeed database.
+
+If set to any symbol except nil, it displays a minimal indicator
+at the top of the buffer instead.
+
+If set to nil, the indicator is disabled."
+  :group 'elfeed-tube
+  :type '(choice (const  :tag "Disabled" nil)
+                 (symbol :tag "Minimal" :value t)
+                 (string :tag "Any string")))
+
+(defcustom elfeed-tube-auto-save-p nil
+  "Save information fetched by elfeed-tube to the Elfeed databse.
+
+This is a boolean. Fetched information is automatically saved
+when this is set to true."
+  :group 'elfeed-tube
+  :type 'boolean)
+
+(defcustom elfeed-tube-auto-fetch-p t
+  "Fetch infor automatically when updating Elfeed or opening entries.
+
+This is a boolean. When set to t, video information will be
+fetched automatically when updating Elfeed or opening video
+entries that don't have metadata."
+  :group 'elfeed-tube
+  :type 'boolean)
+
+(defcustom elfeed-tube-captions-sblock-p t
+  "Whether sponsored segments should be de-emphasized in transcripts."
+  :group 'elfeed-tube
+  :type 'boolean)
+
+(defcustom elfeed-tube-captions-chunk-time 30
+  "Chunk size used when displaying video transcripts.
+
+This is the number of seconds of the transcript to chunk into
+paragraphs or sections. It must be a positive integer."
+  :group 'elfeed-tube
+  :type 'integer)
+
+;; Internal variables
+(defvar elfeed-tube--api-videos-path "/api/v1/videos/")
+(defvar elfeed-tube--info-table (make-hash-table :test #'equal))
+(defvar elfeed-tube--invidious-servers nil)
+(defvar elfeed-tube--sblock-url "https://sponsor.ajay.app")
+(defvar elfeed-tube--sblock-api-path "/api/skipSegments")
+(defvar elfeed-tube-captions-puntcuate-p t)
+(defvar elfeed-tube--api-video-fields
+  '("videoThumbnails" "descriptionHtml" "lengthSeconds"))
+(defvar elfeed-tube--max-retries 2)
+(defvar elfeed-tube--captions-db-dir
+  ;; `file-name-concat' is 28.1+ only
+  (mapconcat #'file-name-as-directory
+             `(,elfeed-db-directory "elfeed-tube" "captions")
+             ""))
+(defvar elfeed-tube--comments-db-dir
+  (mapconcat #'file-name-as-directory
+             `(,elfeed-db-directory "elfeed-tube" "comments")
+             ""))
+
+(defun elfeed-tube-captions-browse-with (follow-fun)
+  "Return a command to browse thing at point with FOLLOW-FUN."
+  (lambda (event)
+    "Translate mouse event to point based button action."
+    (interactive "e")
+    (let ((pos (posn-point (event-end event))))
+      (funcall follow-fun pos))))
+
+(defvar elfeed-tube-captions-map
+  (let ((map (make-sparse-keymap)))
+    (define-key map [mouse-2] (elfeed-tube-captions-browse-with
+                               #'elfeed-tube--browse-at-time))
+    map))
+
+(defface elfeed-tube-chapter-face
+  '((t :inherit (variable-pitch message-header-other) :weight bold))
+  "Face used for chapter headings displayed by Elfeed Tube.")
+
+(defface elfeed-tube-timestamp-face
+  '((t :inherit (variable-pitch message-header-other) :weight semi-bold))
+  "Face used for transcript timestamps displayed by Elfeed Tube.")
+
+(defvar elfeed-tube-captions-faces
+  '((text      . variable-pitch)
+    (timestamp . elfeed-tube-timestamp-face)
+    (intro     . (variable-pitch :inherit shadow))
+    (outro     . (variable-pitch :inherit shadow))
+    (sponsor   . (variable-pitch :inherit shadow
+                                 :strike-through t))
+    (selfpromo . (variable-pitch :inherit shadow
+                                 :strike-through t))
+    (chapter   . elfeed-tube-chapter-face)))
+
+;; Helpers
+(defsubst elfeed-tube-include-p (field)
+  "Check if FIELD should be fetched."
+  (memq field elfeed-tube-fields))
+
+(defsubst elfeed-tube--get-entries ()
+  "Get elfeed entry at point or in active region."
+  (pcase major-mode
+    ('elfeed-search-mode
+     (elfeed-search-selected))
+    ('elfeed-show-mode
+     (list elfeed-show-entry))))
+
+(defsubst elfeed-tube--youtube-p (entry)
+  "Check if ENTRY is a Youtube video entry."
+  (string-match-p elfeed-tube-youtube-regexp
+                  (elfeed-entry-link entry)))
+
+(defsubst elfeed-tube--get-video-id (entry)
+  "Get Youtube video ENTRY's video-id."
+  (when (elfeed-tube--youtube-p entry)
+    (when-let* ((link (elfeed-entry-link entry))
+                (m (string-match
+                    (concat
+                     elfeed-tube-youtube-regexp
+                     "\\(?:watch\\?v=\\)?"
+                     "\\([^?&]+\\)")
+                    link)))
+      (match-string 1 link))))
+
+(defsubst elfeed-tube--random-elt (collection)
+  "Random element from COLLECTION."
+  (and collection
+      (elt collection (cl-random (length collection)))))
+
+(defsubst elfeed-tube-log (level fmt &rest objects)
+  "Log OBJECTS with FMT at LEVEL using `elfeed-log'."
+  (let ((elfeed-log-buffer-name "*elfeed-tube-log*")
+        (elfeed-log-level 'debug))
+    (apply #'elfeed-log level fmt objects)
+    nil))
+
+(defsubst elfeed-tube--attempt-log (attempts)
+  "Format ATTEMPTS as a string."
+  (format "(attempt %d/%d)"
+          (1+ (- elfeed-tube--max-retries
+                 attempts))
+          elfeed-tube--max-retries))
+
+(defsubst elfeed-tube--thumbnail-html (thumb)
+  "HTML for inserting THUMB."
+  (when (and (elfeed-tube-include-p 'thumbnail) thumb)
+    (concat "<br><img src=\"" thumb "\"></a><br><br>")))
+
+(defsubst elfeed-tube--timestamp (time)
+  "Format for TIME as timestamp."
+  (format "%d:%02d" (floor time 60) (mod time 60)))
+
+(defsubst elfeed-tube--same-entry-p (entry1 entry2)
+  "Test if elfeed ENTRY1 and ENTRY2 are the same."
+  (equal (elfeed-entry-id entry1)
+         (elfeed-entry-id entry2)))
+
+(defsubst elfeed-tube--match-captions-langs (lang el)
+  "Find caption track matching LANG in plist EL."
+  (and (or (string-match-p
+            lang
+            (plist-get el :languageCode))
+           (string-match-p
+            lang
+            (thread-first (plist-get el :name)
+                          (plist-get :simpleText))))
+       el))
+
+(defsubst elfeed-tube--truncate (str)
+  "Truncate STR."
+  (truncate-string-to-width str 20))
+
+(defmacro elfeed-tube--with-db (db-dir &rest body)
+  "Execute BODY with DB-DIR set as the `elfeed-db-directory'."
+  (declare (indent defun))
+  `(let ((elfeed-db-directory ,db-dir))
+     ,@body))
+
+(defsubst elfeed-tube--caption-get-face (type)
+  "Get caption face for TYPE."
+  (or (alist-get type elfeed-tube-captions-faces)
+      'variable-pitch))
+
+;; Data structure
+(cl-defstruct
+    (elfeed-tube-item (:constructor elfeed-tube-item--create)
+                      (:copier nil))
+  "Struct to hold elfeed-tube metadata."
+  len thumb desc caps error)
+
+;; Persistence
+(defun elfeed-tube--write-db (entry &optional data-item)
+  "Write struct DATA-ITEM to Elfeed ENTRY in `elfeed-db'."
+  (cl-assert (elfeed-entry-p entry))
+  (when-let* ((data-item (or data-item (elfeed-tube--gethash entry))))
+    (when (elfeed-tube-include-p 'description)
+      (setf (elfeed-entry-content-type entry) 'html)
+      (setf (elfeed-entry-content entry)
+            (when-let ((desc (elfeed-tube-item-desc data-item)))
+              (elfeed-ref desc))))
+    (when (elfeed-tube-include-p 'duration)
+      (setf (elfeed-meta entry :duration)
+            (elfeed-tube-item-len data-item)))
+    (when (elfeed-tube-include-p 'thumbnail)
+      (setf (elfeed-meta entry :thumb)
+            (elfeed-tube-item-thumb data-item)))
+    (when (elfeed-tube-include-p 'captions)
+      (elfeed-tube--with-db elfeed-tube--captions-db-dir
+        (setf (elfeed-meta entry :caps)
+              (when-let ((caption (elfeed-tube-item-caps data-item)))
+                (elfeed-ref (prin1-to-string caption))))))
+    (elfeed-tube-log 'info "[DB][Wrote to DB][video:%s]"
+                     (elfeed-tube--truncate (elfeed-entry-title entry)))
+    t))
+
+(defun elfeed-tube--gethash (entry)
+  "Get hashed elfeed-tube data for ENTRY."
+  (cl-assert (elfeed-entry-p entry))
+  (let ((video-id (elfeed-tube--get-video-id entry)))
+    (gethash video-id elfeed-tube--info-table)))
+
+(defun elfeed-tube--puthash (entry data-item &optional force)
+  "Cache elfeed-dube-item DATA-ITEM for ENTRY."
+  (cl-assert (elfeed-entry-p entry))
+  (cl-assert (elfeed-tube-item-p data-item))
+  (when-let* ((video-id (elfeed-tube--get-video-id entry))
+              (f (or force
+                     (not (gethash video-id elfeed-tube--info-table)))))
+    ;; (elfeed-tube--message
+    ;;  (format "putting %s with data %S" video-id data-item))
+    (puthash video-id data-item elfeed-tube--info-table)))
+
+;; Data munging
+(defun elfeed-tube--get-chapters (desc)
+  "Get chapter timestamps from video DESC."
+  (with-temp-buffer
+    (let ((chapters))
+      (save-excursion (insert desc))
+      (while (re-search-forward
+              "<a href=.*?data-jump-time=\"\\([0-9]+\\)\".*?</a>\\(?:\\s-\\|\\s)\\|-\\)+\\(.*\\)$" nil t)
+        (push (cons (match-string 1)
+                    (thread-last (match-string 2)
+                                 (replace-regexp-in-string
+                                  "&quot;" "\"")
+                                 (replace-regexp-in-string
+                                  "&#39;" "'")
+                                 (replace-regexp-in-string
+                                  "&amp;" "&")
+                                 (string-trim)))
+              chapters))
+      (nreverse chapters))))
+
+(defun elfeed-tube--parse-desc (api-data)
+  "Parse API-DATA for video description."
+  (let* ((length-seconds (plist-get api-data :lengthSeconds))
+         (desc-html (plist-get api-data :descriptionHtml))
+         (chapters (elfeed-tube--get-chapters desc-html))
+         (desc-html (replace-regexp-in-string
+                     "\n" "<br>"
+                     desc-html))
+         (thumb-alist '((large  . 2)
+                        (medium . 3)
+                        (small  . 4)))
+         (thumb-size (cdr-safe (assoc elfeed-tube-thumbnail-size
+                                      thumb-alist)))
+         (thumb))
+    (when (and (elfeed-tube-include-p 'thumbnail)
+               thumb-size)
+      (setq thumb (thread-first
+                    (plist-get api-data :videoThumbnails)
+                    (aref thumb-size)
+                    (plist-get :url))))
+    `(:length ,length-seconds :thumb ,thumb :desc ,desc-html
+      :chaps ,chapters)))
+
+(defun elfeed-tube--extract-captions-urls ()
+  "Extract captionn URLs from Youtube HTML."
+  (catch 'parse-error
+    (if (not (search-forward "\"captions\":" nil t))
+        (throw 'parse-error "captions section not found")
+      (delete-region (point-min) (point))
+      (if (not (search-forward ",\"videoDetails" nil t))
+          (throw 'parse-error "video details not found")
+        (goto-char (match-beginning 0))
+        (delete-region (point) (point-max))
+        (goto-char (point-min))
+        (save-excursion
+          (while (search-forward "\n" nil t)
+            (delete-region (match-beginning 0) (match-end 0))))
+        (condition-case nil
+            (json-parse-buffer :object-type 'plist
+                               :array-type 'list)
+          (json-parse-error (throw 'parse-error "json-parse-error")))))))
+
+(defun elfeed-tube--postprocess-captions (text)
+  "Tweak TEXT for display in the transcript."
+  (thread-last
+    ;; (string-replace "\n" " " text)
+    (replace-regexp-in-string "\n" " " text)
+    (replace-regexp-in-string "\\bi\\b" "I")
+    (replace-regexp-in-string
+     (rx (group (syntax open-parenthesis))
+         (one-or-more (or space punct)))
+     "\\1")
+    (replace-regexp-in-string
+     (rx (one-or-more (or space punct))
+         (group (syntax close-parenthesis)))
+     "\\1")))
+
+;; Content display
+(defvar elfeed-tube--save-state-map
+  (let ((map (make-sparse-keymap)))
+    (define-key map [mouse-2] #'elfeed-tube-save)
+    ;; (define-key map (kbd "RET") #'elfeed-tube--browse-at-time)
+    (define-key map [follow-link] 'mouse-face)
+    map))
+
+(defun elfeed-tube-show (&optional intended-entry)
+  "Show extra video information in an Elfeed entry buffer.
+
+INTENDED-ENTRY is the Elfeed entry being shown. If it is not
+specified use the entry (if any) being displayed in the current
+buffer."
+  (when-let* ((show-buf
+               (if intended-entry
+                   (get-buffer (elfeed-show--buffer-name intended-entry))
+                 (and (elfeed-tube--youtube-p elfeed-show-entry)
+                      (current-buffer))))
+              (entry (buffer-local-value 'elfeed-show-entry show-buf))
+              (intended-entry (or intended-entry entry)))
+    (when (elfeed-tube--same-entry-p entry intended-entry)
+      (with-current-buffer show-buf
+        (let* ((inhibit-read-only t)
+               (feed (elfeed-entry-feed elfeed-show-entry))
+               (base (and feed (elfeed-compute-base (elfeed-feed-url feed))))
+               (data-item (elfeed-tube--gethash entry))
+               insertions)
+          
+          (goto-char (point-max))
+          (when (text-property-search-backward
+                 'face 'message-header-name)
+            (beginning-of-line)
+            (when (looking-at "Transcript:")
+              (text-property-search-backward
+               'face 'message-header-name)
+              (beginning-of-line)))
+          
+          ;; Duration
+          (if-let ((d (elfeed-tube-include-p 'duration))
+                   (duration
+                    (or (and data-item (elfeed-tube-item-len data-item))
+                        (elfeed-meta entry :duration))))
+              (elfeed-tube--insert-duration entry duration)
+            (forward-line 1))
+          
+          ;; DB Status
+          (when (and
+                 elfeed-tube-save-indicator
+                 (or (and data-item (elfeed-tube-item-desc data-item)
+                          (not (elfeed-entry-content entry)))
+                     (and data-item (elfeed-tube-item-thumb data-item)
+                          (not (elfeed-meta entry :thumb)))
+                     (and data-item (elfeed-tube-item-caps data-item)
+                          (not (elfeed-meta entry :caps)))))
+            (let ((prop-list
+                   `(face (:inherit warning :weight bold) mouse-face highlight
+                          help-echo "mouse-1: save this entry to the elfeed-db"
+                          keymap ,elfeed-tube--save-state-map)))
+              (if (stringp elfeed-tube-save-indicator)
+                  (insert (apply #'propertize
+                                 elfeed-tube-save-indicator
+                                 prop-list)
+                          "\n")
+                (save-excursion
+                    (goto-char (point-min))
+                    (end-of-line)
+                    (insert " " (apply #'propertize "[∗]" prop-list))))))
+          
+          ;; Thumbnail
+          (when-let ((th (elfeed-tube-include-p 'thumbnail))
+                     (thumb (or (and data-item (elfeed-tube-item-thumb data-item))
+                                (elfeed-meta entry :thumb))))
+            (elfeed-insert-html (elfeed-tube--thumbnail-html thumb))
+            (push 'thumb insertions))
+          
+          ;; Description
+          (delete-region (point) (point-max))
+          (when (elfeed-tube-include-p 'description)
+            (if-let ((desc (or (and data-item (elfeed-tube-item-desc data-item))
+                               (elfeed-deref (elfeed-entry-content entry)))))
+                (progn (elfeed-insert-html (concat desc "") base)
+                       (push 'desc insertions))))
+          
+          ;; Captions
+          (elfeed-tube--with-db elfeed-tube--captions-db-dir
+            (when-let* ((c (elfeed-tube-include-p 'captions))
+                      (caption
+                       (or (and data-item (elfeed-tube-item-caps data-item))
+                           (and (when-let
+                                    ((capstr (elfeed-deref
+                                              (elfeed-meta entry :caps))))
+                                  (condition-case nil
+                                      (read capstr)
+                                    ('error
+                                     (elfeed-tube-log
+                                      'error "[Show][Captions] DB parse error: %S"
+                                      (elfeed-meta entry :caps)))))))))
+              (when (not (elfeed-entry-content entry))
+                (kill-region (point) (point-max)))
+              (elfeed-tube--insert-captions caption)
+              (push 'caps insertions)))
+          
+          (if insertions
+              (delete-region (point) (point-max))
+            (insert (propertize "\n(empty)\n" 'face 'italic))))
+
+        (setq-local
+         imenu-prev-index-position-function #'elfeed-tube-prev-heading
+         imenu-extract-index-name-function #'elfeed-tube--line-at-point)
+        
+        (goto-char (point-min))))))
+
+(defun elfeed-tube--insert-duration (entry duration)
+  "Insert the video DURATION for ENTRY into an Elfeed entry buffer."
+  (if (not (integerp duration))
+      (elfeed-tube-log
+       'warn "[Duration][video:%s][Not available]"
+       (elfeed-tube--truncate (elfeed-entry-title entry)))
+    (let ((inhibit-read-only t))
+      (beginning-of-line)
+      (if (looking-at "Duration:")
+          (delete-region (point)
+                         (save-excursion (end-of-line)
+                                         (point)))
+	(end-of-line)
+	(insert "\n"))
+      (insert (propertize "Duration: " 'face 'message-header-name)
+              (propertize (elfeed-tube--timestamp duration)
+                          'face 'message-header-other)
+              "\n")
+      t)))
+
+(defun elfeed-tube--insert-captions (caption)
+  "Insert the video CAPTION for ENTRY into an Elfeed entry buffer."
+  (if  (and (listp caption)
+            (eq (car-safe caption) 'transcript))
+      (let ((caption-ordered
+             (cl-loop for (type (start _) text) in (cddr caption)
+                      with chapters = (car-safe (cdr caption))
+                      with pstart = 0
+                      for chapter = (car-safe chapters)
+                      for oldtime = 0 then time
+                      for time = (string-to-number (cdr start))
+                      for chap-begin-p =
+                      (and chapter
+                           (>= (floor time) (string-to-number (car chapter))))
+
+                      if (and
+                          (or chap-begin-p
+                              (< (mod (floor time)
+                                      elfeed-tube-captions-chunk-time)
+                                 (mod (floor oldtime)
+                                      elfeed-tube-captions-chunk-time)))
+                          (> (abs (- time pstart)) 3))
+                      collect (list pstart time para) into result and
+                      do (setq para nil pstart time)
+                      
+                      if chap-begin-p
+                      do (setq chapters (cdr-safe chapters))
+                      
+                      collect (cons time
+                                    (propertize
+                                     ;; (elfeed-tube--postprocess-captions text)
+                                     (replace-regexp-in-string "\n" " " text)
+                                     'face (elfeed-tube--caption-get-face type)
+                                     'type type))
+                      into para
+                      finally return (nconc result (list (list pstart time para)))))
+            (inhibit-read-only t))
+        (goto-char (point-max))
+        (insert "\n"
+                (propertize "Transcript:" 'face 'message-header-name)
+                "\n\n")
+        (cl-loop for (start end para) in caption-ordered
+                 with chapters = (car-safe (cdr caption))
+                 with vspace = (propertize " " 'face 'variable-pitch)
+                 for chapter = (car-safe chapters)
+                 with beg = (point) do
+                 (progn
+                   (when (and chapter (>= start (string-to-number (car chapter))))
+                     (insert (propertize (cdr chapter)
+                                         'face
+                                         (elfeed-tube--caption-get-face 'chapter)
+                                         'timestamp (string-to-number (car chapter))
+                                         'mouse-face 'highlight
+                                         'help-echo #'elfeed-tube--caption-echo
+                                         'keymap elfeed-tube-captions-map
+                                         'type 'chapter)
+                             (propertize "\n\n" 'hard t))
+                     (setq chapters (cdr chapters)))
+                   (insert
+                    (propertize (format "[%s] - [%s]:"
+                                        (elfeed-tube--timestamp start)
+                                        (elfeed-tube--timestamp end))
+                                'face (elfeed-tube--caption-get-face
+                                       'timestamp))
+                    (propertize "\n" 'hard t)
+                    (string-join
+                     (mapcar (lambda (tx-cons)
+                               (propertize (cdr tx-cons)
+                                           'timestamp
+                                           (car tx-cons)
+                                           'mouse-face
+                                           'highlight
+                                           'help-echo
+                                           #'elfeed-tube--caption-echo
+                                           'keymap
+                                           elfeed-tube-captions-map))
+                             para)
+                     vspace)
+                    (propertize "\n\n" 'hard t)))
+                 finally (when-let* ((w shr-width)
+                                     (fill-column w)
+                                     (use-hard-newlines t))
+                           (fill-region beg (point) nil t))))
+    (elfeed-tube-log 'debug
+                     "[Captions][video:%s][Not available]"
+                     (or (and elfeed-show-entry (truncate-string-to-width
+                                                 elfeed-show-entry 20))
+                         ""))))
+
+(defvar elfeed-tube--captions-echo-message
+  (lambda (time) (format "mouse-2: open at %s (web browser)" time)))
+
+(defun elfeed-tube--caption-echo (_ _ pos)
+  "Caption echo text at position POS."
+  (concat
+   (when-let ((type (get-text-property pos 'type)))
+     (when (not (eq type 'text))
+       (format "  segment: %s\n\n" (symbol-name type))))
+   (let ((time (elfeed-tube--timestamp
+                (get-text-property pos 'timestamp))))
+     (funcall elfeed-tube--captions-echo-message time))))
+
+;; Setup
+(defun elfeed-tube--auto-fetch (&optional entry)
+  "Fetch video information for Elfeed ENTRY and display it if possible.
+
+If ENTRY is not specified, use the entry (if any) corresponding
+to the current buffer."
+  (when elfeed-tube-auto-fetch-p
+    (aio-listen
+     (elfeed-tube--fetch-1 (or entry elfeed-show-entry))
+     (lambda (fetched-p)
+       (when (funcall fetched-p)
+         (elfeed-tube-show (or entry elfeed-show-entry)))))))
+
+(defun elfeed-tube-setup ()
+  "Set up elfeed-tube.
+
+This does the following:
+- Enable fetching video metadata when running `elfeed-update'.
+- Enable showing video metadata in `elfeed-show' buffers if available."
+  (add-hook 'elfeed-new-entry-hook #'elfeed-tube--auto-fetch)
+  (advice-add 'elfeed-show-entry
+              :after #'elfeed-tube--auto-fetch)
+  (advice-add elfeed-show-refresh-function
+              :after #'elfeed-tube-show)
+  t)
+
+(defun elfeed-tube-teardown ()
+  "Undo the effects of `elfeed-tube-setup'."
+  (advice-remove elfeed-show-refresh-function #'elfeed-tube-show)
+  (advice-remove 'elfeed-show-entry #'elfeed-tube--auto-fetch)
+  (remove-hook 'elfeed-new-entry-hook #'elfeed-tube--auto-fetch)
+  t)
+
+;; From aio-contrib.el: the workhorse
+(defun elfeed-tube-curl-enqueue (url &rest args)
+  "Fetch URL with ARGS using Curl.
+
+Like `elfeed-curl-enqueue' but delivered by a promise.
+
+The result is a plist with the following keys:
+:success -- the callback argument (t or nil)
+:headers -- `elfeed-curl-headers'
+:status-code -- `elfeed-curl-status-code'
+:error-message -- `elfeed-curl-error-message'
+:location -- `elfeed-curl-location'
+:content -- (buffer-string)"
+  (let* ((promise (aio-promise))
+         (cb (lambda (success)
+               (let ((result (list :success success
+                                   :headers elfeed-curl-headers
+                                   :status-code elfeed-curl-status-code
+                                   :error-message elfeed-curl-error-message
+                                   :location elfeed-curl-location
+                                   :content (buffer-string))))
+                 (aio-resolve promise (lambda () result))))))
+    (prog1 promise
+      (apply #'elfeed-curl-enqueue url cb args))))
+
+;; Fetchers
+(aio-defun elfeed-tube--get-invidious-servers ()
+  (let* ((instances-url (concat "https://api.invidious.io/instances.json"
+                                "?pretty=1&sort_by=type,users"))
+         (result (aio-await (elfeed-tube-curl-enqueue instances-url :method "GET")))
+         (status-code (plist-get result :status-code))
+         (servers (plist-get result :content)))
+    (when (= status-code 200)
+      (thread-last
+        (json-parse-string servers :object-type 'plist :array-type 'list)
+        (cl-remove-if-not (lambda (s) (eq t (plist-get (cadr s) :api))))
+        (mapcar #'car)))))
+
+(aio-defun elfeed-tube--get-invidious-url ()
+  (or elfeed-tube-invidious-url
+      (let ((servers
+             (or elfeed-tube--invidious-servers
+                 (setq elfeed-tube--invidious-servers
+                       (elfeed--shuffle
+                        (aio-await (elfeed-tube--get-invidious-servers)))))))
+        (car servers))))
+
+(defsubst elfeed-tube--nrotate-invidious-servers ()
+  "Rotate the list of Invidious servers in place."
+  (setq elfeed-tube--invidious-servers
+        (nconc (cdr elfeed-tube--invidious-servers)
+               (list (car elfeed-tube--invidious-servers)))))
+
+(aio-defun elfeed-tube--fetch-captions-tracks (entry)
+  (let* ((video-id (elfeed-tube--get-video-id entry))
+         (url (format "https://youtube.com/watch?v=%s" video-id))
+         (response (aio-await (elfeed-tube-curl-enqueue url :method "GET")))
+         (status-code (plist-get response :status-code)))
+    (if-let*
+        ((s (= status-code 200))
+         (data (with-temp-buffer
+                 (save-excursion (insert (plist-get response :content)))
+                 (elfeed-tube--extract-captions-urls))))
+      ;; (message "%S" data)
+        (thread-first
+          data
+          (plist-get :playerCaptionsTracklistRenderer)
+          (plist-get :captionTracks))
+      (elfeed-tube-log 'debug "[%s][Caption tracks]: %s"
+                       url (plist-get response :error-message))
+      (elfeed-tube-log 'warn "[Captions][video:%s]: Not available"
+                       (elfeed-tube--truncate (elfeed-entry-title entry))))))
+
+(aio-defun elfeed-tube--fetch-captions-url (caption-plist entry)
+  (let* ((case-fold-search t)
+         (chosen-caption
+          (cl-loop
+           for lang in elfeed-tube-captions-languages
+           for pick = (cl-some
+                       (lambda (el) (elfeed-tube--match-captions-langs lang el))
+                       caption-plist)
+           until pick
+           finally return pick))
+         base-url language)
+    (cond
+     ((not caption-plist)
+      (elfeed-tube-log
+       'warn "[Captions][video:%s][No languages]"
+       (elfeed-tube--truncate (elfeed-entry-title entry))))
+     ((not chosen-caption)
+      (elfeed-tube-log
+       'warn
+       "[Captions][video:%s][Not available in %s]"
+       (elfeed-tube--truncate (elfeed-entry-title entry))
+       (string-join elfeed-tube-captions-languages ", ")))
+     (t (setq base-url (plist-get chosen-caption :baseUrl)
+              language (thread-first (plist-get chosen-caption :name)
+                                     (plist-get :simpleText)))
+        (let* ((response (aio-await (elfeed-tube-curl-enqueue base-url :method "GET")))
+               (captions (plist-get response :content))
+               (status-code (plist-get response :status-code)))
+          (if (= status-code 200)
+              (cons language captions)
+            (elfeed-tube-log
+             'error
+             "[Caption][video:%s][lang:%s]: %s"
+             (elfeed-tube--truncate (elfeed-entry-title entry))
+             language
+             (plist-get response :error-message))))))))
+
+(defvar elfeed-tube--sblock-categories
+  '("sponsor" "intro" "outro" "selfpromo" "interaction"))
+
+(aio-defun elfeed-tube--fetch-captions-sblock (entry)
+  (when-let* ((categories
+               (json-serialize (vconcat elfeed-tube--sblock-categories)))
+              (api-url (url-encode-url
+                        (concat elfeed-tube--sblock-url
+                                elfeed-tube--sblock-api-path
+                                "?videoID=" (elfeed-tube--get-video-id entry)
+                                "&categories=" categories)))
+              (response (aio-await (elfeed-tube-curl-enqueue
+                                    api-url :method "GET")))
+              (status-code (plist-get response :status-code))
+              (content-json (plist-get response :content)))
+    (if (= status-code 200)
+        (condition-case nil
+            (json-parse-string content-json
+                           :object-type 'plist
+                           :array-type 'list)
+          (json-parse-error
+           (elfeed-tube-log
+            'error
+            "[Sponsorblock][video:%s]: JSON malformed"
+            (elfeed-tube--truncate (elfeed-entry-title entry)))))
+      (elfeed-tube-log
+       'error
+       "[Sponsorblock][video:%s]: %s"
+       (elfeed-tube--truncate (elfeed-entry-title entry))
+       (plist-get response :error-message)))))
+
+(aio-defun elfeed-tube--fetch-captions (entry)
+  (pcase-let* ((urls (aio-await (elfeed-tube--fetch-captions-tracks entry)))
+               (`(,language . ,xmlcaps) (aio-await (elfeed-tube--fetch-captions-url urls entry)))
+               (sblock (and elfeed-tube-captions-sblock-p
+                            (aio-await (elfeed-tube--fetch-captions-sblock entry))))
+               (parsed-caps))
+    ;; (print (elfeed-entry-title entry) (get-buffer "*scratch*"))
+    ;; (print language (get-buffer "*scratch*"))
+    (when xmlcaps
+      (setq parsed-caps (with-temp-buffer
+                          (insert xmlcaps)
+                          (goto-char (point-min))
+                          (dolist (reps '(("&amp;#39;"  . "'")
+                                          ("&amp;quot;" . "\"")
+                                          ("\n"         . " ")
+                                          (" "          . "")))
+                            (save-excursion
+                              (while (search-forward (car reps) nil t)
+                                (replace-match (cdr reps) nil t))))
+                          (libxml-parse-xml-region (point-min) (point-max)))))
+    (when parsed-caps
+      (when (and elfeed-tube-captions-sblock-p sblock)
+        (setq parsed-caps (elfeed-tube--sblock-captions sblock parsed-caps)))
+      (when (and elfeed-tube-captions-puntcuate-p
+                 (string-match-p "auto-generated" language))
+        (elfeed-tube--npreprocess-captions parsed-caps))
+      parsed-caps)))
+
+(defun elfeed-tube--npreprocess-captions (captions)
+  "Preprocess CAPTIONS."
+  (cl-loop for text-element in (cddr captions)
+           for (_ _ text) in (cddr captions)
+           do (setf (nth 2 text-element)
+                    (cl-reduce
+                     (lambda (accum reps)
+                       (replace-regexp-in-string (car reps) (cdr reps) accum))
+                     `(("\\bi\\b" . "I")
+                       (,(rx (group (syntax open-parenthesis))
+                             (one-or-more (or space punct)))
+                        . "\\1")
+                       (,(rx (one-or-more (or space punct))
+                             (group (syntax close-parenthesis)))
+                        . "\\1"))
+                     :initial-value text))
+           finally return captions))
+
+(defun elfeed-tube--sblock-captions (sblock captions)
+  "Add sponsor data from SBLOCK into CAPTIONS."
+  (let ((sblock-filtered
+         (cl-loop for skip in sblock
+                  for cat = (plist-get skip :category)
+                  when (member cat elfeed-tube--sblock-categories)
+                  collect `(:category ,cat :segment ,(plist-get skip :segment)))))
+    (cl-loop for telm in (cddr captions)
+             do (when-let
+                    ((cat
+                      (cl-some
+                       (lambda (skip)
+                         (pcase-let ((cat (intern (plist-get skip :category)))
+                                     (`(,beg ,end) (plist-get skip :segment))
+                                     (sn (string-to-number (cdaadr telm))))
+                           (and (> sn beg) (< sn end) cat)))
+                       sblock-filtered)))
+                  (setf (car telm) cat))
+             finally return captions)))
+
+(aio-defun elfeed-tube--fetch-desc (entry &optional attempts)
+  (let* ((attempts (or attempts (1+ elfeed-tube--max-retries)))
+         (video-id (elfeed-tube--get-video-id entry)))
+    (when (> attempts 0)
+      (if-let ((invidious-url (aio-await (elfeed-tube--get-invidious-url))))
+          (let* ((api-url (concat
+                           invidious-url
+                           elfeed-tube--api-videos-path
+                           video-id
+                           "?fields="
+                           (string-join elfeed-tube--api-video-fields ",")))
+                 (desc-log (elfeed-tube-log
+                            'debug
+                            "[Description][video:%s][Fetch:%s]"
+                            (elfeed-tube--truncate (elfeed-entry-title entry))
+                            api-url))
+                 (api-response (aio-await (elfeed-tube-curl-enqueue
+                                           api-url
+                                           :method "GET")))
+                 (api-status (plist-get api-response :status-code))
+                 (api-data (plist-get api-response :content))
+                 (json-object-type (quote plist)))
+            (if (= api-status 200)
+                ;; Return data
+                (condition-case error
+                    (prog1
+                        (elfeed-tube--parse-desc
+                         (json-parse-string api-data :object-type 'plist)))
+                  (json-parse-error
+                   (elfeed-tube-log
+                    'error
+                    "[Description][video:%s]: JSON malformed %s"
+                    (elfeed-tube--truncate (elfeed-entry-title entry))
+                    (elfeed-tube--attempt-log attempts))
+                   (elfeed-tube--nrotate-invidious-servers)
+                   (aio-await
+                    (elfeed-tube--fetch-desc entry (- attempts 1)))))
+              ;; Retry #attempts times
+              (elfeed-tube-log 'error
+               "[Description][video:%s][%s]: %s %s"
+               (elfeed-tube--truncate (elfeed-entry-title entry))
+               api-url
+               (plist-get api-response :error-message)
+               (elfeed-tube--attempt-log attempts))
+              (elfeed-tube--nrotate-invidious-servers)
+              (aio-await
+               (elfeed-tube--fetch-desc entry (- attempts 1)))))
+
+        (message
+         "Could not find a valid Invidious url. Please customize `elfeed-tube-invidious-url'.")
+        nil))))
+
+(aio-defun elfeed-tube--with-label (label func &rest args)
+  (cons label (aio-await (apply func args))))
+
+(aio-defun elfeed-tube--fetch-1 (entry &optional force-fetch)
+  (when (elfeed-tube--youtube-p entry)
+    (let* ((fields (aio-make-select))
+           (cached (elfeed-tube--gethash entry))
+           desc thumb duration caps sblock chaps error)
+      
+      ;; When to fetch a field:
+      ;; - force-fetch is true: always fetch
+      ;; - entry not cached, field not saved: fetch
+      ;; - entry not cached but saved: don't fetch
+      ;; - entry is cached with errors: don't fetch
+      ;; - entry is cached without errors, field not empty: don't fetch
+      ;; - entry is saved and field not empty: don't fetch
+
+      ;; Fetch description?
+      (when (and (cl-some #'elfeed-tube-include-p
+                          '(description duration thumbnail chapters))
+                 (or force-fetch
+                     (not (or (and cached
+                                   (or (cl-intersection
+                                        '(desc duration thumb chapters)
+                                        (elfeed-tube-item-error cached))
+                                       (elfeed-tube-item-len cached)
+                                       (elfeed-tube-item-desc cached)
+                                       (elfeed-tube-item-thumb cached)))
+                              (or (elfeed-entry-content entry)
+                                  (elfeed-meta entry :thumb)
+                                  (elfeed-meta entry :duration))))))
+        (aio-select-add fields
+                        (elfeed-tube--with-label
+                         'desc #'elfeed-tube--fetch-desc entry)))
+
+      ;; Fetch captions?
+      (when (and (elfeed-tube-include-p 'captions)
+                 (or force-fetch
+                     (not (or (and cached
+                                   (or (elfeed-tube-item-caps cached)
+                                       (memq 'caps (elfeed-tube-item-error cached))))
+                              (elfeed-ref-p
+                               (elfeed-meta entry :caps))))))
+        (aio-select-add fields
+                        (elfeed-tube--with-label
+                         'caps #'elfeed-tube--fetch-captions entry))
+        ;; Fetch caption sblocks?
+        (when (and nil elfeed-tube-captions-sblock-p)
+          (aio-select-add fields
+                          (elfeed-tube--with-label
+                           'sblock #'elfeed-tube--fetch-captions-sblock entry))))
+      
+      ;; Record fields?
+      (while (aio-select-promises fields)
+        (pcase-let ((`(,label . ,data)
+                     (aio-await (aio-await (aio-select fields)))))
+          (pcase label
+            ('desc
+             (if data
+                 (progn
+                   (when (elfeed-tube-include-p 'thumbnail)
+                     (setf thumb
+                           (plist-get data :thumb)))
+                   (when (elfeed-tube-include-p 'description)
+                     (setf desc
+                           (plist-get data :desc)))
+                   (when (elfeed-tube-include-p 'duration)
+                     (setf duration
+                           (plist-get data :length)))
+                   (when (elfeed-tube-include-p 'chapters)
+                     (setf chaps
+                           (plist-get data :chaps))))
+               (setq error (append error '(desc duration thumb)))))
+            ('caps
+             (if data
+                 (setf caps data)
+               (push 'caps error)))
+            ('sblock
+             (and data (setf sblock data))))))
+      
+      ;; Add (optional) sblock and chapter info to caps
+      (when caps
+        (when sblock
+          (setf caps (elfeed-tube--sblock-captions sblock caps)))
+        (when chaps
+          (setf (cadr caps) chaps)))
+      
+      (if (and elfeed-tube-auto-save-p
+               (or duration caps desc thumb))
+          ;; Store in db
+          (progn (elfeed-tube--write-db
+                  entry
+                  (elfeed-tube-item--create
+                   :len duration :desc desc :thumb thumb
+                   :caps caps))
+                 (elfeed-tube-log
+                  'info "Saved to elfeed-db: %s"
+                  (elfeed-entry-title entry)))
+        ;; Store in session cache
+        (when (or duration caps desc thumb error)
+          (elfeed-tube--puthash
+           entry
+           (elfeed-tube-item--create
+            :len duration :desc desc :thumb thumb
+            :caps caps :error error)
+           force-fetch)))
+      ;; Return t if something was fetched
+      (and (or duration caps desc thumb) t))))
+
+;; Interaction
+(defun elfeed-tube--browse-at-time (pos)
+  "Browse video URL at POS at current time."
+  (interactive "d")
+  (when-let ((time (get-text-property pos 'timestamp)))
+    (browse-url (concat "https://youtube.com/watch?v="
+                        (elfeed-tube--get-video-id elfeed-show-entry)
+                        "&t="
+                        (number-to-string (floor time))))))
+
+;; Entry points
+;;;###autoload (autoload 'elfeed-tube-fetch "elfeed-tube" "Fetch youtube metadata for Youtube video or Elfeed entry ENTRIES." t nil)
+(aio-defun elfeed-tube-fetch (entries &optional force-fetch)
+  "Fetch youtube metadata for Elfeed ENTRIES.
+
+In elfeed-show buffers, ENTRIES is the entry being displayed.
+
+In elfeed-search buffers, ENTRIES is the entry at point, or all
+entries in the region when the region is active.
+
+Outside of Elfeed, prompt the user for any Youtube video URL and
+generate an Elfeed-like summary buffer for it.
+
+With optional prefix argument FORCE-FETCH, force refetching of
+the metadata for ENTRIES.
+
+If you want to always add this metadata to the database, consider
+setting `elfeed-tube-auto-save-p'. To customize what kinds of
+metadata are fetched, customize TODO
+`elfeed-tube-fields'."
+  (interactive (list (or (elfeed-tube--ensure-list (elfeed-tube--get-entries))
+                         (read-from-minibuffer "Youtube video URL: "))
+                     current-prefix-arg))
+  (if (not (listp entries))
+      (elfeed-tube--fake-entry entries force-fetch)
+    (if (not elfeed-tube-fields)
+        (message "Nothing to fetch! Customize `elfeed-tube-fields'.")
+      (dolist (entry (elfeed-tube--ensure-list entries))
+        (aio-await (elfeed-tube--fetch-1 entry force-fetch))
+        (elfeed-tube-show entry)))))
+
+(defun elfeed-tube-save (entries)
+  "Save elfeed-tube youtube metadata for ENTRIES to the elfeed database.
+
+ENTRIES is the current elfeed entry in elfeed-show buffers. In
+elfeed-search buffers it's the entry at point or the selected
+entries when the region is active."
+  (interactive (list (elfeed-tube--get-entries)))
+  (dolist (entry entries)
+    (if (elfeed-tube--write-db entry)
+        (progn (message "Wrote to elfeed-db: \"%s\"" (elfeed-entry-title entry))
+               (when (derived-mode-p 'elfeed-show-mode)
+                 (elfeed-show-refresh)))
+      (message "elfeed-db already contains: \"%s\"" (elfeed-entry-title entry)))))
+
+(provide 'elfeed-tube)
+;;; elfeed-tube.el ends here
blob - /dev/null
blob + b4889a2367f5f1eac44401981375fd9ed391abeb (mode 644)
--- /dev/null
+++ elpa/elfeed-tube-mpv-20220704.1952/elfeed-tube-mpv-autoloads.el
@@ -0,0 +1,28 @@
+;;; elfeed-tube-mpv-autoloads.el --- automatically extracted autoloads (do not edit)   -*- lexical-binding: t -*-
+;; Generated by the `loaddefs-generate' function.
+
+;; This file is part of GNU Emacs.
+
+;;; Code:
+
+(add-to-list 'load-path (directory-file-name
+                         (or (file-name-directory #$) (car load-path))))
+
+
+
+;;; Generated autoloads from elfeed-tube-mpv.el
+
+(register-definition-prefixes "elfeed-tube-mpv" '("elfeed-tube-mpv"))
+
+;;; End of scraped data
+
+(provide 'elfeed-tube-mpv-autoloads)
+
+;; Local Variables:
+;; version-control: never
+;; no-byte-compile: t
+;; no-update-autoloads: t
+;; coding: utf-8-emacs-unix
+;; End:
+
+;;; elfeed-tube-mpv-autoloads.el ends here
blob - /dev/null
blob + c91a0a7483282900f5b3afa7074335ffec8e339b (mode 644)
--- /dev/null
+++ elpa/elfeed-tube-mpv-20220704.1952/elfeed-tube-mpv-pkg.el
@@ -0,0 +1,2 @@
+;;; Generated package description from elfeed-tube-mpv.el  -*- no-byte-compile: t -*-
+(define-package "elfeed-tube-mpv" "20220704.1952" "Control mpv from Elfeed" '((emacs "27.1") (elfeed-tube "0.10") (mpv "0.2.0")) :commit "5817c91f5b3b7159965aa73839d2a0a08fd952bd" :authors '(("Karthik Chikmagalur" . "karthikchikmagalur@gmail.com")) :maintainer '("Karthik Chikmagalur" . "karthikchikmagalur@gmail.com") :keywords '("news" "hypermedia") :url "https://github.com/karthink/elfeed-tube")
blob - /dev/null
blob + ea64ba379178311474878edad55d8fe884c5579a (mode 644)
--- /dev/null
+++ elpa/elfeed-tube-mpv-20220704.1952/elfeed-tube-mpv.el
@@ -0,0 +1,321 @@
+;;; elfeed-tube-mpv.el --- Control mpv from Elfeed  -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2022  Karthik Chikmagalur
+
+;; Author: Karthik Chikmagalur <karthikchikmagalur@gmail.com>
+;; version: 0.10
+;; Package-Version: 20220704.1952
+;; Package-Commit: 5817c91f5b3b7159965aa73839d2a0a08fd952bd
+;; Keywords: news, hypermedia
+;; Package-Requires: ((emacs "27.1") (elfeed-tube "0.10") (mpv "0.2.0"))
+;; URL: https://github.com/karthink/elfeed-tube
+
+;; SPDX-License-Identifier: UNLICENSE
+
+;; This file is NOT part of GNU Emacs.
+
+;;; Commentary:
+;;
+;; This package provides integration with the mpv video player for `elfeed-tube'
+;; entries, which see.
+;;
+;; With `elfeed-tube-mpv' loaded, clicking on a transcript segment in an Elfeed
+;; Youtube video feed entry will launch mpv at that time, or seek to that point
+;; if already playing.
+;;
+;; It defines two commands and a minor mode:
+;;
+;; - `elfeed-tube-mpv': Start an mpv session that is "connected" to an Elfeed
+;; entry corresponding to a Youtube video. You can use this command to start
+;; playback, or seek in mpv to a transcript segment, or enqueue a video in mpv
+;; if one is already playing. Call with a prefix argument to spawn a new
+;; instance of mpv instead.
+;;
+;; - `elfeed-tube-mpv-where': Jump in Emacs to the transcript position
+;; corresponding to the current playback time in mpv.
+;;
+;; - `elfeed-tube-mpv-follow-mode': Follow along in the transcript in Emacs to
+;; the video playback.
+;;
+;;; Code:
+(require 'pulse)
+(require 'elfeed-tube)
+(require 'mpv)
+
+(defcustom elfeed-tube-mpv-options
+  '(;; "--ytdl-format=bestvideo[height<=?480]+bestaudio/best"
+    "--cache=yes"
+    ;; "--script-opts=osc-scalewindowed=2,osc-visibility=always"
+    )
+  "List of command line arguments to pass to mpv.
+
+If the mpv library is available, these are appended to
+`mpv-default-options'. Otherwise mpv is started with these options.
+
+Each element in this list is a string. Examples:
+- \"--cache=yes\"
+- \"--osc=no\""
+  :group 'elfeed-tube
+  :type '(repeat string))
+(defvar elfeed-tube-mpv--available-p
+  (and (executable-find "mpv")
+       (or (executable-find "youtube-dl")
+           (executable-find "yt-dlp"))))
+(defvar-local elfeed-tube-mpv--follow-p nil)
+(defvar elfeed-tube-mpv--follow-timer nil)
+(defvar-local elfeed-tube-mpv--overlay nil)
+(defvar elfeed-tube-mpv-hook nil
+  "Hook run before starting mpv playback in an elfeed-show buffer.
+
+Each function must accept one argument, the current Elfeed
+entry.")
+
+(let ((map elfeed-tube-captions-map))
+  (define-key map (kbd "RET") #'elfeed-tube-mpv)
+  (define-key map [mouse-1] (elfeed-tube-captions-browse-with
+                             #'elfeed-tube-mpv))
+  (define-key map (kbd "C-<down-mouse-1>")
+    (elfeed-tube-captions-browse-with
+     (lambda (pos) (elfeed-tube-mpv pos t)))))
+
+(setq-default
+ elfeed-tube--captions-echo-message
+ (defsubst elfeed-tube-mpv--echo-message (time)
+   (format
+    "  mouse-1: open at %s (mpv)
+C-mouse-1: open at %s (mpv, new instance)
+  mouse-2: open at %s (web browser)"
+    time time time)))
+
+(defsubst elfeed-tube-mpv--check-path (video-url)
+  "Check if currently playing mpv video matches VIDEO-URL."
+  (condition-case nil
+      (apply #'string=
+             (mapcar
+              (lambda (s)
+                (replace-regexp-in-string
+                 "&t=[0-9.]*" "" s))
+              (list (mpv-get-property "path")
+                    video-url)))
+    ('error nil)))
+
+(defsubst elfeed-tube-mpv--set-timer (entry)
+  "Start mpv position update timer for ENTRY."
+  (setq elfeed-tube-mpv--follow-timer
+        (run-with-timer
+         4 1.5 #'elfeed-tube-mpv--follow entry)))
+
+(defsubst elfeed-tube-mpv--overlay-clear ()
+  "Clear mpv position overlay."
+  (progn (when (timerp elfeed-tube-mpv--follow-timer)
+           (cancel-timer elfeed-tube-mpv--follow-timer))
+         (when (overlayp elfeed-tube-mpv--overlay)
+           (delete-overlay elfeed-tube-mpv--overlay))))
+
+(defun elfeed-tube-mpv (pos &optional arg)
+  "Start or seek an mpv session connected to an Elfeed entry.
+
+Call this command with point POS on an Elfeed entry in an Elfeed
+Search buffer, or anywhere in an Elfeed Entry, to play the
+corresponding video. When called with point in a transcript
+segment, seek here or start a new session as appropriate. If a
+connected mpv session for a different video is already running
+enqueue this video instead.
+
+With prefix argument ARG always start a new, unnconnected mpv
+session."
+  (interactive (list (point)
+                     current-prefix-arg))
+  (if (not elfeed-tube-mpv--available-p)
+      (message "Could not find mpv + youtube-dl/yt-dlp in PATH.")
+    (when-let* ((time (or (get-text-property pos 'timestamp) 0))
+                (entry (or elfeed-show-entry
+                           (elfeed-search-selected 'ignore-region)))
+                (video-id (elfeed-tube--get-video-id entry))
+                (video-url (concat "https://youtube.com/watch?v="
+                                   video-id
+                                   "&t="
+                                   (number-to-string (floor time))))
+                (args (append elfeed-tube-mpv-options (list video-url))))
+      (run-hook-with-args 'elfeed-tube-mpv-hook entry)
+      ;; (pulse-momentary-highlight-one-line)
+      (if (and (not arg) (require 'mpv nil t))
+          (if (mpv-live-p)
+              (if (elfeed-tube-mpv--check-path video-url)
+                  (unless (= 0 time)
+                    (mpv-seek time))
+                (mpv--enqueue `("loadfile" ,video-url "append")
+                              #'ignore)
+                (message "Added to playlist: %s"
+                         (elfeed-entry-title entry)))
+            (apply #'mpv-start args)
+            (message
+             (concat "Starting mpv: "
+                     (propertize "Connected to Elfeed ✓"
+                                 'face 'success)))
+            (when elfeed-tube-mpv--follow-p
+              (elfeed-tube-mpv--set-timer entry)))
+        (apply #'start-process
+               (concat "elfeed-tube-mpv-"
+                       (elfeed-tube--get-video-id elfeed-show-entry))
+               nil "mpv" args)
+        (message (concat "Starting new mpv instance: "
+                         (propertize "Not connected to Elfeed ❌"
+                                     'face 'error)))))))
+
+(defun elfeed-tube-mpv--follow (entry-playing)
+  "Folllow the ENTRY-PLAYING in mpv in Emacs.
+
+This function is intended to be run on a timer when
+`elfeed-tube-mpv-follow-mode' is active."
+  (if (not (mpv-live-p))
+      (elfeed-tube-mpv--overlay-clear)
+    (when-let ((entry-buf (get-buffer
+                           (elfeed-show--buffer-name
+                            entry-playing))))
+      (when (and (or (derived-mode-p 'elfeed-show-mode)
+		     (window-live-p (get-buffer-window entry-buf)))
+		 (elfeed-tube--same-entry-p
+                  (buffer-local-value 'elfeed-show-entry entry-buf)
+		  entry-playing)
+		 (eq (mpv-get-property "pause")
+		     json-false))
+	(condition-case nil
+	    (when-let ((mpv-time (mpv-get-property "time-pos")))
+	      (with-current-buffer entry-buf
+
+		;; Create overlay
+		(unless (overlayp elfeed-tube-mpv--overlay)
+		  (save-excursion
+		    (goto-char (point-min))
+		    (text-property-search-forward
+		     'timestamp)
+		    (setq elfeed-tube-mpv--overlay
+			  (make-overlay (point) (point)))
+		    (overlay-put elfeed-tube-mpv--overlay
+				 'face '(:inverse-video t))))
+		
+                ;; Handle narrowed buffers
+                (when (buffer-narrowed-p)
+                  (save-excursion
+                    (let ((min (point-min))
+                          (max (point-max))
+                          beg end)
+                      (goto-char min)
+                      (setq beg (prop-match-value
+                                 (text-property-search-forward
+                                  'timestamp)))
+                      (goto-char max)
+                      (widen)
+                      (setq end (prop-match-value
+                                 (text-property-search-forward
+                                  'timestamp)))
+                      (narrow-to-region min max)
+                      (cond
+                       ((and beg (< mpv-time beg))
+                        (mpv-set-property "time-pos" (1- beg)))
+                       ((and end (> mpv-time end))
+                        (mpv-set-property "time-pos" (1+ end))
+                        (mpv-set-property "pause" t))))))
+                
+                ;; Update overlay
+                (when-let ((next (elfeed-tube-mpv--where-internal mpv-time)))
+                  (goto-char next)
+                  (move-overlay elfeed-tube-mpv--overlay
+				(save-excursion (beginning-of-visual-line) (point))
+				(save-excursion (end-of-visual-line) (point))))))
+	  ('error nil))))))
+
+(defun elfeed-tube-mpv--where-internal (mpv-time)
+  "Return the point in the Elfeed buffer that corresponds to time MPV-TIME."
+  (save-excursion
+      (while (not (get-text-property (point) 'timestamp))
+        (goto-char (or (previous-single-property-change
+		        (point) 'timestamp)
+		       (next-single-property-change
+		        (point) 'timestamp))))
+
+      (if (> (get-text-property (point) 'timestamp)
+	     mpv-time)
+	  (let ((match (text-property-search-backward
+		        'timestamp mpv-time
+		        (lambda (mpv cur)
+			  (< (or cur
+			         (get-text-property
+				  (1+ (point))
+				  'timestamp))
+			     (- mpv 1))))))
+	    (goto-char (prop-match-end match))
+	    (text-property-search-forward 'timestamp)
+	    (min (1+ (point)) (point-max)))
+        (let ((match (text-property-search-forward
+		      'timestamp mpv-time
+		      (lambda (mpv cur) (if cur (> cur (- mpv 1)))))))
+          (prop-match-beginning match)))))
+
+(defun elfeed-tube-mpv-where ()
+  "Jump to the current mpv position in a video transcript."
+  (interactive)
+  (cond
+   ((not (featurep 'mpv))
+    (message "mpv-where requires the mpv package. You can install it with M-x `package-install' RET mpv RET."))
+   ((not (and (derived-mode-p 'elfeed-show-mode)
+              (elfeed-tube--youtube-p elfeed-show-entry)))
+    (message "Not in an elfeed-show buffer for a Youtube video!"))
+   ((not (mpv-live-p))
+    (message "No running instance of mpv is connected to Emacs."))
+   ((or (previous-single-property-change
+	 (point) 'timestamp)
+	(next-single-property-change
+	 (point) 'timestamp))
+    (goto-char (elfeed-tube-mpv--where-internal
+                (mpv-get-property "time-pos")))
+    (let ((pulse-delay 0.08)
+          (pulse-iterations 16))
+      (pulse-momentary-highlight-one-line)))
+   (t (message "Transcript location not found in buffer."))))
+
+(define-minor-mode elfeed-tube-mpv-follow-mode
+  "Follow along with mpv in elfeed-show buffers.
+
+This appliies to Youtube feed entries in Elfeed. When the video
+player mpv is started from this buffer (from any location in the
+transcript), turning on this minor-mode will cause the cursor to
+track the currently playing segment in mpv. You can still click
+anywhere in the transcript to seek to that point in the video."
+  :global nil
+  :version "0.10"
+  :lighter " (-->)"
+  :keymap (let ((map (make-sparse-keymap)))
+            (prog1 map
+              (define-key map " " #'mpv-pause)))
+  :group 'elfeed-tube
+  (if elfeed-tube-mpv-follow-mode
+      (cond
+       
+       ((not (require 'mpv nil t))
+        (message "mpv-follow-mode requires the mpv package. You can install it with M-x `package-install' RET mpv RET.")
+        (elfeed-tube-mpv-follow-mode -1))
+       
+       ((not (derived-mode-p 'elfeed-show-mode))
+        (message "mpv-follow-mode only works in elfeed-show buffers.")
+        (elfeed-tube-mpv-follow-mode -1))
+       
+       (t (if-let* ((entry elfeed-show-entry)
+                    (video-id (elfeed-tube--get-video-id entry))
+                    (video-url
+                     (concat "https://youtube.com/watch?v="
+                             video-id)))
+              (if (and (mpv-live-p) (elfeed-tube-mpv--check-path video-url))
+                  (elfeed-tube-mpv--set-timer entry)
+                (setq-local elfeed-tube-mpv--follow-p t))
+            (message "Not a youtube video buffer!")
+            (elfeed-tube-mpv-follow-mode -1))))
+    
+    (setq-local elfeed-tube-mpv--follow-p nil)
+    (when (timerp elfeed-tube-mpv--follow-timer)
+      (cancel-timer elfeed-tube-mpv--follow-timer))
+    (elfeed-tube-mpv--overlay-clear)))
+
+(provide 'elfeed-tube-mpv)
+;;; elfeed-tube-mpv.el ends here
blob - /dev/null
blob + 8bed68cd1dfab3b72ce0d4bb8a4ff56a641d32c1 (mode 644)
--- /dev/null
+++ elpa/mpv-20211228.2043/mpv-autoloads.el
@@ -0,0 +1,101 @@
+;;; mpv-autoloads.el --- automatically extracted autoloads (do not edit)   -*- lexical-binding: t -*-
+;; Generated by the `loaddefs-generate' function.
+
+;; This file is part of GNU Emacs.
+
+;;; Code:
+
+(add-to-list 'load-path (directory-file-name
+                         (or (file-name-directory #$) (car load-path))))
+
+
+
+;;; Generated autoloads from mpv.el
+
+(autoload 'mpv-play "mpv" "\
+Start an mpv process playing the file at PATH.
+
+You can use this with `org-add-link-type' or `org-file-apps'.
+See `mpv-start' if you need to pass further arguments and
+`mpv-default-options' for default options.
+
+(fn PATH)" t nil)
+(autoload 'mpv-kill "mpv" "\
+Kill the mpv process." t nil)
+(autoload 'mpv-pause "mpv" "\
+Pause or unpause playback." t nil)
+(autoload 'mpv-insert-playback-position "mpv" "\
+Insert the current playback position at point.
+
+When called with a non-nil ARG, insert a timer list item like `org-timer-item'.
+
+(fn &optional ARG)" t nil)
+(autoload 'mpv-seek-to-position-at-point "mpv" "\
+Jump to playback position as inserted by `mpv-insert-playback-position'.
+
+This can be used with the `org-open-at-point-functions' hook." t nil)
+(autoload 'mpv-speed-set "mpv" "\
+Set playback speed to FACTOR.
+
+(fn FACTOR)" t nil)
+(autoload 'mpv-speed-increase "mpv" "\
+Increase playback speed by STEPS factors of `mpv-speed-step'.
+
+(fn STEPS)" t nil)
+(autoload 'mpv-speed-decrease "mpv" "\
+Decrease playback speed by STEPS factors of `mpv-speed-step'.
+
+(fn STEPS)" t nil)
+(autoload 'mpv-volume-set "mpv" "\
+Set playback volume to FACTOR.
+
+(fn FACTOR)" t nil)
+(autoload 'mpv-volume-increase "mpv" "\
+Increase playback volume by STEPS factors of `mpv-volume-step'.
+
+(fn STEPS)" t nil)
+(autoload 'mpv-volume-decrease "mpv" "\
+Decrease playback volume by STEPS factors of `mpv-volume-step'.
+
+(fn STEPS)" t nil)
+(autoload 'mpv-seek "mpv" "\
+Seek to the given (absolute) time in SECONDS.
+A negative value is interpreted relative to the end of the file.
+
+(fn SECONDS)" t nil)
+(autoload 'mpv-seek-forward "mpv" "\
+Seek forward ARG seconds.
+If ARG is numeric, it is used as the number of seconds.  Else each use
+of \\[universal-argument] will add another `mpv-seek-step' seconds.
+
+(fn ARG)" t nil)
+(autoload 'mpv-seek-backward "mpv" "\
+Seek backward ARG seconds.
+If ARG is numeric, it is used as the number of seconds.  Else each use
+of \\[universal-argument] will add another `mpv-seek-step' seconds.
+
+(fn ARG)" t nil)
+(autoload 'mpv-revert-seek "mpv" "\
+Undo the previous seek command." t nil)
+(autoload 'mpv-playlist-next "mpv" "\
+Go to the next entry on the playlist." t nil)
+(autoload 'mpv-playlist-prev "mpv" "\
+Go to the previous entry on the playlist." t nil)
+(autoload 'mpv-version "mpv" "\
+Return the mpv version string.
+When called interactively, also show a more verbose version in
+the echo area." t nil)
+(register-definition-prefixes "mpv" '("mpv-"))
+
+;;; End of scraped data
+
+(provide 'mpv-autoloads)
+
+;; Local Variables:
+;; version-control: never
+;; no-byte-compile: t
+;; no-update-autoloads: t
+;; coding: utf-8-emacs-unix
+;; End:
+
+;;; mpv-autoloads.el ends here
blob - /dev/null
blob + 30c3e5bfffe770dbb9364259f2c2f64b36b8a414 (mode 644)
--- /dev/null
+++ elpa/mpv-20211228.2043/mpv-pkg.el
@@ -0,0 +1,2 @@
+;;; Generated package description from mpv.el  -*- no-byte-compile: t -*-
+(define-package "mpv" "20211228.2043" "control mpv for easy note-taking" '((cl-lib "0.5") (emacs "25.1") (json "1.3") (org "8.0")) :commit "4fd8baa508dbc1a6b42b4e40292c0dbb0f19c9b9" :authors '(("Johann Klähn" . "johann@jklaehn.de")) :maintainer '("Johann Klähn" . "johann@jklaehn.de") :keywords '("tools" "multimedia") :url "https://github.com/kljohann/mpv.el")
blob - /dev/null
blob + bc63924e052e8df1cb4a9d6bcfe8021ea3dc977d (mode 644)
--- /dev/null
+++ elpa/mpv-20211228.2043/mpv.el
@@ -0,0 +1,416 @@
+;;; mpv.el --- control mpv for easy note-taking  -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2014-2018  Johann Klähn
+
+;; Author: Johann Klähn <johann@jklaehn.de>
+;; URL: https://github.com/kljohann/mpv.el
+;; Package-Version: 20211228.2043
+;; Package-Commit: 4fd8baa508dbc1a6b42b4e40292c0dbb0f19c9b9
+;; Version: 0.2.0
+;; Keywords: tools, multimedia
+;; Package-Requires: ((cl-lib "0.5") (emacs "25.1") (json "1.3") (org "8.0"))
+
+;; This program is free software; you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation, either version 3 of the License, or
+;; (at your option) any later version.
+
+;; This program is distributed in the hope that it will be useful,
+;; but WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+;; GNU General Public License for more details.
+
+;; You should have received a copy of the GNU General Public License
+;; along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+;;; Commentary:
+
+;; This package is a potpourri of helper functions to control a mpv
+;; process via its IPC interface.  You might want to add the following
+;; to your init file:
+;;
+;; (org-add-link-type "mpv" #'mpv-play)
+;; (defun org-mpv-complete-link (&optional arg)
+;;   (replace-regexp-in-string
+;;    "file:" "mpv:"
+;;    (org-file-complete-link arg)
+;;    t t))
+;; (add-hook 'org-open-at-point-functions #'mpv-seek-to-position-at-point)
+
+;;; Code:
+
+(require 'cl-lib)
+(require 'json)
+(require 'org)
+(require 'org-timer)
+(require 'tq)
+
+(defgroup mpv nil
+  "Customization group for mpv."
+  :prefix "mpv-"
+  :group 'external)
+
+(defcustom mpv-executable "mpv"
+  "Name or path to the mpv executable."
+  :type 'file
+  :group 'mpv)
+
+(defcustom mpv-default-options nil
+  "List of default options to be passed to mpv."
+  :type '(repeat string)
+  :group 'mpv)
+
+(defcustom mpv-speed-step 1.10
+  "Scale factor used when adjusting playback speed."
+  :type 'number
+  :group 'mpv)
+
+(defcustom mpv-volume-step 1.50
+  "Scale factor used when adjusting volume."
+  :type 'number
+  :group 'mpv)
+
+(defcustom mpv-seek-step 5
+  "Step size in seconds used when seeking."
+  :type 'number
+  :group 'mpv)
+
+(defcustom mpv-on-event-hook nil
+  "Hook to run when an event message is received.
+The hook will be called with the parsed JSON message as its only an
+argument.  See \"List of events\" in the mpv man page."
+  :type 'hook
+  :group 'mpv)
+
+(defcustom mpv-on-start-hook nil
+  "Hook to run when a new mpv process is started.
+The hook will be called with the arguments passed to `mpv-start'."
+  :type 'hook
+  :group 'mpv)
+
+(defcustom mpv-on-exit-hook nil
+  "Hook to run when the mpv process dies."
+  :type 'hook
+  :group 'mpv)
+
+(defvar mpv--process nil)
+(defvar mpv--queue nil)
+
+(defun mpv-live-p ()
+  "Return non-nil if inferior mpv is running."
+  (and mpv--process (eq (process-status mpv--process) 'run)))
+
+(defun mpv-start (&rest args)
+  "Start an mpv process with the specified ARGS.
+
+If there already is an mpv process controlled by this Emacs instance,
+it will be killed.  Options specified in `mpv-default-options' will be
+prepended to ARGS."
+  (mpv-kill)
+  (let ((socket (make-temp-name
+                 (expand-file-name "mpv-" temporary-file-directory))))
+    (setq mpv--process
+          (apply #'start-process "mpv-player" nil mpv-executable
+                 "--no-terminal"
+                 (concat "--input-unix-socket=" socket)
+                 (append mpv-default-options args)))
+    (set-process-query-on-exit-flag mpv--process nil)
+    (set-process-sentinel
+     mpv--process
+     (lambda (process _event)
+       (when (memq (process-status process) '(exit signal))
+         (mpv-kill)
+         (when (file-exists-p socket)
+           (with-demoted-errors (delete-file socket)))
+         (run-hooks 'mpv-on-exit-hook))))
+    (with-timeout
+        (0.5 (mpv-kill)
+             (error "Failed to connect to mpv"))
+      (while (not (file-exists-p socket))
+        (sleep-for 0.05)))
+    (setq mpv--queue (tq-create
+                  (make-network-process :name "mpv-socket"
+                                        :family 'local
+                                        :service socket)))
+    (set-process-filter
+     (tq-process mpv--queue)
+     (lambda (_proc string)
+       (mpv--tq-filter mpv--queue string)))
+    (run-hook-with-args 'mpv-on-start-hook args)
+    t))
+
+(defun mpv--as-strings (command)
+  "Convert COMMAND to a list of strings."
+  (mapcar (lambda (arg)
+            (if (numberp arg)
+                (number-to-string arg)
+              arg))
+          command))
+
+(defun mpv--enqueue (command fn &optional delay-command)
+  "Add COMMAND to the transaction queue.
+
+FN will be called with the corresponding answer.
+If DELAY-COMMAND is non-nil, delay sending this question until
+the process has finished replying to any previous questions.
+This produces more reliable results with some processes.
+
+Note that we do not use the regexp and closure arguments of
+`tq-enqueue', see our custom implementation of `tq-process-buffer'
+below."
+  (when (mpv-live-p)
+    (tq-enqueue
+     mpv--queue
+     (concat (json-encode `((command . ,(mpv--as-strings command)))) "\n")
+     "" nil fn delay-command)
+    t))
+
+(defun mpv-run-command (command &rest arguments)
+  "Send a COMMAND to mpv, passing the remaining ARGUMENTS.
+Block while waiting for the response."
+  (when (mpv-live-p)
+    (let* ((response
+            (cl-block mpv-run-command-wait-for-response
+              (mpv--enqueue
+               (cons command arguments)
+               (lambda (response)
+                 (cl-return-from mpv-run-command-wait-for-response
+                   response)))
+              (while (mpv-live-p)
+                (sleep-for 0.05))))
+           (status (alist-get 'error response))
+           (data (alist-get 'data response)))
+    (unless (string-equal status "success")
+      (error "`%s' failed: %s" command status))
+    data)))
+
+(defun mpv--tq-filter (tq string)
+  "Append to the queue's buffer and process the new data.
+
+TQ is a transaction queue created by `tq-create'.
+STRING is the data fragment received from the process.
+
+This is a verbatim copy of `tq-filter' that uses
+`mpv--tq-process-buffer' instead of `tq-process-buffer'."
+  (let ((buffer (tq-buffer tq)))
+    (when (buffer-live-p buffer)
+      (with-current-buffer buffer
+        (goto-char (point-max))
+        (insert string)
+        (mpv--tq-process-buffer tq)))))
+
+(defun mpv--tq-process-buffer (tq)
+  "Check TQ's buffer for a JSON response.
+
+Replacement for `tq-process-buffer' that ignores regular expressions
+\(answers are always passed to the first handler in the queue) and
+passes unsolicited event messages to `mpv-on-event-hook'."
+  (goto-char (point-min))
+  (skip-chars-forward "^{")
+  (let ((answer (ignore-errors (json-read))))
+    (when answer
+      (delete-region (point-min) (point))
+      ;; event messages have form {"event": ...}
+      ;; answers have form {"error": ..., "data": ...}
+      (cond
+       ((assoc 'event answer)
+        (run-hook-with-args 'mpv-on-event-hook answer))
+       ((not (tq-queue-empty tq))
+        (unwind-protect
+            (funcall (tq-queue-head-fn tq) answer)
+          (tq-queue-pop tq))))
+      ;; Recurse to check for further JSON messages.
+      (mpv--tq-process-buffer tq))))
+
+;;;###autoload
+(defun mpv-play (path)
+  "Start an mpv process playing the file at PATH.
+
+You can use this with `org-add-link-type' or `org-file-apps'.
+See `mpv-start' if you need to pass further arguments and
+`mpv-default-options' for default options."
+  (interactive "fFile: ")
+  (mpv-start (expand-file-name path)))
+
+;;;###autoload
+(defun mpv-kill ()
+  "Kill the mpv process."
+  (interactive)
+  (when mpv--queue
+    (tq-close mpv--queue))
+  (when (mpv-live-p)
+    (kill-process mpv--process))
+  (with-timeout
+      (0.5 (error "Failed to kill mpv"))
+    (while (mpv-live-p)
+      (sleep-for 0.05)))
+  (setq mpv--process nil)
+  (setq mpv--queue nil))
+
+;;;###autoload
+(defun mpv-pause ()
+  "Pause or unpause playback."
+  (interactive)
+  (mpv--enqueue '("cycle" "pause") #'ignore))
+
+(defun mpv-get-property (property)
+  "Return the value of the given PROPERTY."
+  (mpv-run-command "get_property" property))
+
+(defun mpv-set-property (property value)
+  "Set the given PROPERTY to VALUE."
+  (mpv-run-command "set_property" property value))
+
+(defun mpv-cycle-property (property)
+  "Cycle the given PROPERTY."
+  (mpv-run-command "cycle" property))
+
+(defun mpv-get-playback-position ()
+  "Return the current playback position in seconds."
+  (mpv-get-property "playback-time"))
+
+(defun mpv-get-duration ()
+  "Return the estimated total duration of the current file in seconds."
+  (mpv-get-property "duration"))
+
+;;;###autoload
+(defun mpv-insert-playback-position (&optional arg)
+  "Insert the current playback position at point.
+
+When called with a non-nil ARG, insert a timer list item like `org-timer-item'."
+  (interactive "P")
+  (let ((time (mpv-get-playback-position)))
+    (funcall
+     (if arg #'mpv--position-insert-as-org-item #'insert)
+     (org-timer-secs-to-hms (round time)))))
+
+(defun mpv--position-insert-as-org-item (time-string)
+  "Insert a description-type item with the playback position TIME-STRING.
+
+See `org-timer-item' which this is based on."
+  (cl-letf (((symbol-function 'org-timer)
+             (lambda (&optional _restart no-insert)
+               (funcall
+                (if no-insert #'identity #'insert)
+                (concat time-string " ")))))
+    (org-timer-item)))
+
+;;;###autoload
+(defun mpv-seek-to-position-at-point ()
+  "Jump to playback position as inserted by `mpv-insert-playback-position'.
+
+This can be used with the `org-open-at-point-functions' hook."
+  (interactive)
+  (save-excursion
+    (skip-chars-backward ":[:digit:]" (point-at-bol))
+    (when (looking-at "[0-9]+:[0-9]\\{2\\}:[0-9]\\{2\\}")
+      (let ((secs (org-timer-hms-to-secs (match-string 0))))
+        (when (>= secs 0)
+          (mpv-seek secs))))))
+
+;;;###autoload
+(defun mpv-speed-set (factor)
+  "Set playback speed to FACTOR."
+  (interactive "nFactor: ")
+  (mpv--enqueue `("set" "speed" ,(abs factor)) #'ignore))
+
+;;;###autoload
+(defun mpv-speed-increase (steps)
+  "Increase playback speed by STEPS factors of `mpv-speed-step'."
+  (interactive "p")
+  (let ((factor (if (>= steps 0)
+                    (* steps mpv-speed-step)
+                  (/ 1 (* (- steps) mpv-speed-step)))))
+    (mpv--enqueue `("multiply" "speed" ,factor) #'ignore)))
+
+;;;###autoload
+(defun mpv-speed-decrease (steps)
+  "Decrease playback speed by STEPS factors of `mpv-speed-step'."
+  (interactive "p")
+  (mpv-speed-increase (- steps)))
+
+;;;###autoload
+(defun mpv-volume-set (factor)
+  "Set playback volume to FACTOR."
+  (interactive "nFactor: ")
+  (mpv--enqueue `("set" "volume" ,(abs factor)) #'ignore))
+
+;;;###autoload
+(defun mpv-volume-increase (steps)
+  "Increase playback volume by STEPS factors of `mpv-volume-step'."
+  (interactive "p")
+  (let ((factor (if (>= steps 0)
+                    (* steps mpv-volume-step)
+                  (/ 1 (* (- steps) mpv-volume-step)))))
+    (mpv--enqueue `("multiply" "volume" ,factor) #'ignore)))
+
+;;;###autoload
+(defun mpv-volume-decrease (steps)
+  "Decrease playback volume by STEPS factors of `mpv-volume-step'."
+  (interactive "p")
+  (mpv-volume-increase (- steps)))
+
+(defun mpv--raw-prefix-to-seconds (arg)
+  "Convert raw prefix argument ARG to seconds using `mpv-seek-step'.
+Numeric arguments will be treated as seconds, repeated use
+\\[universal-argument] will be multiplied with `mpv-seek-step'."
+  (if (numberp arg)
+      arg
+    (* mpv-seek-step
+       (cl-signum (or (car arg) 1))
+       (log (abs (or (car arg) 4)) 4))))
+
+;;;###autoload
+(defun mpv-seek (seconds)
+  "Seek to the given (absolute) time in SECONDS.
+A negative value is interpreted relative to the end of the file."
+  (interactive "nPosition in seconds: ")
+  (mpv--enqueue `("seek" ,seconds "absolute") #'ignore))
+
+;;;###autoload
+(defun mpv-seek-forward (arg)
+  "Seek forward ARG seconds.
+If ARG is numeric, it is used as the number of seconds.  Else each use
+of \\[universal-argument] will add another `mpv-seek-step' seconds."
+  (interactive "P")
+  (mpv--enqueue `("seek" ,(mpv--raw-prefix-to-seconds arg) "relative") #'ignore))
+
+;;;###autoload
+(defun mpv-seek-backward (arg)
+  "Seek backward ARG seconds.
+If ARG is numeric, it is used as the number of seconds.  Else each use
+of \\[universal-argument] will add another `mpv-seek-step' seconds."
+  (interactive "P")
+  (mpv-seek-forward (- (mpv--raw-prefix-to-seconds arg))))
+
+;;;###autoload
+(defun mpv-revert-seek ()
+  "Undo the previous seek command."
+  (interactive)
+  (mpv--enqueue '("revert-seek") #'ignore))
+
+;;;###autoload
+(defun mpv-playlist-next ()
+  "Go to the next entry on the playlist."
+  (interactive)
+  (mpv--enqueue '("playlist-next") #'ignore))
+
+;;;###autoload
+(defun mpv-playlist-prev ()
+  "Go to the previous entry on the playlist."
+  (interactive)
+  (mpv--enqueue '("playlist-prev") #'ignore))
+
+;;;###autoload
+(defun mpv-version ()
+  "Return the mpv version string.
+When called interactively, also show a more verbose version in
+the echo area."
+  (interactive)
+  (let ((version (cadr (split-string (car (process-lines mpv-executable "--version"))))))
+    (prog1 version
+      (if (called-interactively-p 'interactive)
+	  (message "mpv %s" version)))))
+
+(provide 'mpv)
+;;; mpv.el ends here
blob - 8999d2ce245e9e7b5fea73ab662a790446be2818
blob + 5323063104908f4a7dbe44f5ddaaec669ef18cbe
--- init.el
+++ init.el
@@ -20,6 +20,10 @@
   (require 'paredit-menu))
 (with-eval-after-load 'restclient
   (require 'restclient-capf))
+(with-eval-after-load 'elfeed
+  (require 'elfeed-tube)
+  (require 'elfeed-tube-mpv)
+  (elfeed-tube-setup))
 
 (setq org-roam-v2-ack t)
 
@@ -55,7 +59,13 @@
 (defmacro lh/define-keys (keymap keys-alist &optional after)
   (let ((defines (seq-map
                   (lambda (x)
-                    `(define-key ,keymap (kbd ,(car x)) #',(cdr x)))
+                    `(define-key
+                      ,keymap
+                      ,(let ((key (car x)))
+                         (cond
+                          ((stringp key) `(kbd ,key))
+                          (t key)))
+                      #',(cdr x)))
                   keys-alist)))
     (if (null after)
         (cons 'progn defines)
@@ -169,6 +179,14 @@
                 (("<mouse-8>" . sly-inspector-pop)
                  ("<mouse-9>" . sly-inspector-next))
                 sly)
+(lh/define-keys elfeed-show-mode-map
+                (("F" . elfeed-tube-fetch)
+                 ([remap save-buffer] . elfeed-tube-save))
+                elfeed)
+(lh/define-keys elfeed-search-mode-map
+                (("F" . elfeed-tube-fetch)
+                 ([remap save-buffer] . elfeed-tube-save))
+                elfeed)
 
 (defun corfu-insert-with-return ()
   (interactive)
@@ -269,7 +287,7 @@
      ("melpa-stable" . "https://stable.melpa.org/packages/")
      ("melpa" . "https://melpa.org/packages/")))
  '(package-selected-packages
-   '(restclient-jq graphviz-dot-mode consult-eglot jq-mode multiple-cursors ob-restclient restclient vterm deadgrep helpful pdf-tools paredit-menu paredit corfu sly eglot aggressive-indent project nov nhexl-mode elfeed magit yaml-mode json-mode lua-mode go-mode geiser-guile geiser org-roam org-contrib org ace-window expand-region consult marginalia uuidgen request diminish which-key))
+   '(elfeed-tube-mpv elfeed-tube restclient-jq graphviz-dot-mode consult-eglot jq-mode multiple-cursors ob-restclient restclient vterm deadgrep helpful pdf-tools paredit-menu paredit corfu sly eglot aggressive-indent project nov nhexl-mode elfeed magit yaml-mode json-mode lua-mode go-mode geiser-guile geiser org-roam org-contrib org ace-window expand-region consult marginalia uuidgen request diminish which-key))
  '(pcomplete-ignore-case t t)
  '(pixel-scroll-precision-mode t)
  '(read-buffer-completion-ignore-case t)