dotemacs

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

commit 3ef2dd014718873e076696886b31f5504544dd67
parent d14a2a71ba45a5adc3c136a7d3c1af59cd1ce3dc
Author: Lukas Henkel <lh@entf.net>
Date:   Sun, 10 Jul 2022 12:57:44 +0200

Add elfeed-tube

Diffstat:
Aelpa/aio-20200610.1904/README.md | 182+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aelpa/aio-20200610.1904/UNLICENSE | 24++++++++++++++++++++++++
Aelpa/aio-20200610.1904/aio-autoloads.el | 28++++++++++++++++++++++++++++
Aelpa/aio-20200610.1904/aio-pkg.el | 10++++++++++
Aelpa/aio-20200610.1904/aio.el | 470+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aelpa/elfeed-tube-20220703.2128/elfeed-tube-autoloads.el | 35+++++++++++++++++++++++++++++++++++
Aelpa/elfeed-tube-20220703.2128/elfeed-tube-pkg.el | 14++++++++++++++
Aelpa/elfeed-tube-20220703.2128/elfeed-tube-utils.el | 484+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aelpa/elfeed-tube-20220703.2128/elfeed-tube.el | 1172+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aelpa/elfeed-tube-mpv-20220704.1952/elfeed-tube-mpv-autoloads.el | 28++++++++++++++++++++++++++++
Aelpa/elfeed-tube-mpv-20220704.1952/elfeed-tube-mpv-pkg.el | 2++
Aelpa/elfeed-tube-mpv-20220704.1952/elfeed-tube-mpv.el | 321+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aelpa/mpv-20211228.2043/mpv-autoloads.el | 101+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aelpa/mpv-20211228.2043/mpv-pkg.el | 2++
Aelpa/mpv-20211228.2043/mpv.el | 416+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minit.el | 22++++++++++++++++++++--
16 files changed, 3309 insertions(+), 2 deletions(-)

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