commit - d14a2a71ba45a5adc3c136a7d3c1af59cd1ce3dc
commit + 3ef2dd014718873e076696886b31f5504544dd67
blob - /dev/null
blob + 8267bec6651fb2b856b99092b7acc61ee9ee450a (mode 644)
--- /dev/null
+++ elpa/aio-20200610.1904/README.md
+# 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
+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
+;;; 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
+(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
+;;; 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
+;;; 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
+(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
+;;; 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
+;;; 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
+ """ "\"")
+ (replace-regexp-in-string
+ "'" "'")
+ (replace-regexp-in-string
+ "&" "&")
+ (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 '(("&#39;" . "'")
+ ("&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
+;;; 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
+;;; 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
+;;; 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
+;;; 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
+;;; 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
+;;; 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
(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)
(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)
(("<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)
("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)