;;; livie.el --- Livie is Video in Emacs -*- lexical-binding: t; -*- ;; Copyright (C) 2018 - 2021 ;;; Authors: ;; Charlie Ritter ;; Jesus E. ;; Gabriele Rastello ;;; Commentary: ;; livie grabs a list of youtube videos based on a search. ;; the user can then select a video to watch through `livie-player' ;;; Code: (require 'cl-lib) (require 'json) (require 'seq) (defgroup livie '() "Livie is Video in Emacs" :prefix "livie-" :group 'livie) (defcustom livie-sort-criterion 'relevance "Criterion to sort the results of the search query." :type 'symbol :options '(relevance rating upload_date view_count) :group 'livie) (defvar livie-invidious-api-url "https://invidious.048596.xyz" "URL to Invidious instance.") (defvar livie-invidious-default-query-fields "author,lengthSeconds,title,videoId,authorId,viewCount,published" "Default fields of interest for video search.") (defvar livie-videos '() "List of videos currently on display.") (defvar livie-published-date-time-string "%Y-%m-%d" "Time-string used to render the published date of the video. See `format-time-string' for information on how to edit this variable.") (defvar-local livie-current-page 1 "Current page of the current `livie-search-term'") (defvar-local livie-search-term "" "Current search string as used by `livie-search'") (defvar livie-author-name-reserved-space 20 "Number of characters reserved for the channel's name in the *livie* buffer. Note that there will always 3 extra spaces for eventual dots (for names that are too long).") (defvar livie-title-video-reserved-space 100 "Number of characters reserved for the video title in the *livie* buffer. Note that there will always 3 extra spaces for eventual dots (for names that are too long).") (defface livie-video-published-face '((((class color) (background light)) (:foreground "#00C853")) (((class color) (background dark)) (:foreground "#00E676"))) "Face used for the video published date.") (defface livie-channel-name-face '((((class color) (background light)) (:foreground "#FFC400")) (((class color) (background dark)) (:foreground "#FFFF00"))) "Face used for channel names.") (defface livie-video-length-face '((((class color) (background light)) (:foreground "#6A1B9A")) (((class color) (background dark)) (:foreground "#AA00FF"))) "Face used for the video length.") (defface livie-video-view-face '((((class color) (background light)) (:foreground "#00695C")) (((class color) (background dark)) (:foreground "#00BFA5"))) "Face used for the video views.") (defvar livie-mode-map (let ((map (make-sparse-keymap))) (suppress-keymap map) (define-key map "q" #'livie-quit) (define-key map "h" #'describe-mode) (define-key map "n" #'next-line) (define-key map "p" #'previous-line) (define-key map (kbd "") #'next-line) (define-key map (kbd "") #'previous-line) (define-key map "s" #'livie-search) (define-key map ">" #'livie-search-next-page) (define-key map "<" #'livie-search-previous-page) (define-key map (kbd "") 'livie-watch-this-video) map) "Keymap for `livie-mode'.") (define-derived-mode livie-mode text-mode "livie-mode" (setq buffer-read-only t) (buffer-disable-undo) (make-local-variable 'livie-videos)) (defun livie-quit () "Quit livie buffer." (interactive) (quit-window)) (defun livie--format-author (name) "Format a channel NAME to be inserted in the *livie* buffer." (let* ((n (string-width name)) (extra-chars (- n livie-author-name-reserved-space)) (formatted-string (if (<= extra-chars 0) (concat name (make-string (abs extra-chars) ?\ ) " ") (concat (seq-subseq name 0 livie-author-name-reserved-space) "...")))) (propertize formatted-string 'face 'livie-channel-name-face))) (defun livie--format-title (title) "Format a video TITLE to be inserted in the *livie* buffer." (let* ((n (string-width title)) (extra-chars (- n livie-title-video-reserved-space)) (formatted-string (if (<= extra-chars 0) (concat title (make-string (abs extra-chars) ?\ ) " ") (concat (seq-subseq title 0 livie-title-video-reserved-space) "...")))) formatted-string)) (defun livie--format-video-length (seconds) "Given an amount of SECONDS, format it nicely to be inserted in the *livie* buffer." (let ((formatted-string (concat (format-seconds "%.2h" seconds) ":" (format-seconds "%.2m" (mod seconds 3600)) ":" (format-seconds "%.2s" (mod seconds 60))))) (propertize formatted-string 'face 'livie-video-length-face))) (defun livie--format-video-views (views) "Format video VIEWS to be inserted in the *livie* buffer." (propertize (concat "[views:" (number-to-string views) "]") 'face 'livie-video-view-face)) (defun livie--format-video-published (published) "Format video PUBLISHED date to be inserted in the *livie* buffer." (propertize (format-time-string livie-published-date-time-string (seconds-to-time published)) 'face 'livie-video-published-face)) (defun livie--insert-video (video) "Insert `VIDEO' in the current buffer." (insert (livie--format-video-published (livie-video-published video)) " " (livie--format-author (livie-video-author video)) " " (livie--format-video-length (livie-video-length video)) " " (livie--format-title (livie-video-title video)) " " (livie--format-video-views (livie-video-views video)))) (defun livie--draw-buffer () "Draws the livie buffer i.e. clear everything and write down all videos in `livie-videos'." (let ((inhibit-read-only t)) (erase-buffer) (setf header-line-format (concat "Search results for " (propertize livie-search-term 'face 'livie-video-published-face) ", page " (number-to-string livie-current-page))) (seq-do (lambda (v) (livie--insert-video v) (insert "\n")) livie-videos) (goto-char (point-min)))) (defun livie-search (query) "Search youtube for `QUERY', and redraw the buffer." (interactive "sSearch: ") (setf livie-current-page 1) (setf livie-search-term query) (setf livie-videos (livie--query query livie-current-page)) (livie--draw-buffer)) (defun livie-search-next-page () "Switch to the next page of the current search. Redraw the buffer." (interactive) (setf livie-videos (livie--query livie-search-term (1+ livie-current-page))) (setf livie-current-page (1+ livie-current-page)) (livie--draw-buffer)) (defun livie-search-previous-page () "Switch to the previous page of the current search. Redraw the buffer." (interactive) (when (> livie-current-page 1) (setf livie-videos (livie--query livie-search-term (1- livie-current-page))) (setf livie-current-page (1- livie-current-page)) (livie--draw-buffer))) (defun livie-get-current-video () "Get the currently selected video." (aref livie-videos (1- (line-number-at-pos)))) (defun livie-watch-this-video () "Stream video at point in mpv." (interactive) (let* ((video (livie-get-current-video)) (id (livie-video-id video))) (start-process "livie mpv" nil "mpv" (concat "https://www.youtube.com/watch?v=" id)) "--ytdl-format=bestvideo[height<=?720]+bestaudio/best") (delete-other-windows) (message "Starting streaming...")) (defun livie-buffer () "Name for the main livie buffer." (get-buffer-create "*livie*")) ;;;###autoload (defun livie () "Enter livie." (interactive) (switch-to-buffer (livie-buffer)) (unless (eq major-mode 'livie-mode) (livie-mode)) (when (seq-empty-p livie-search-term) (call-interactively #'livie-search))) ;; Youtube interface stuff below. (cl-defstruct (livie-video (:constructor livie-video--create) (:copier nil)) "Information about a Youtube video." (title "" :read-only t) (id 0 :read-only t) (author "" :read-only t) (authorId "" :read-only t) (length 0 :read-only t) (views 0 :read-only t) (published 0 :read-only t)) (defun livie--API-call (method args) "Perform a call to the invidious API method METHOD passing ARGS. Curl is used to perform the request. An error is thrown if it exits with a non zero exit code otherwise the request body is parsed by `json-read' and returned." (with-temp-buffer (let ((exit-code (call-process "curl" nil t nil "--silent" "-X" "GET" (concat livie-invidious-api-url "/api/v1/" method "?" (url-build-query-string args))))) (unless (= exit-code 0) (error "Curl had problems connecting to Invidious")) (goto-char (point-min)) (json-read)))) (defun livie--query (string n) "Query youtube for STRING, return the Nth page of results." (let ((videos (livie--API-call "search" `(("q", string) ("sort_by", (symbol-name livie-sort-criterion)) ("page", n) ("fields", livie-invidious-default-query-fields))))) (dotimes (i (length videos)) (let ((v (aref videos i))) (aset videos i (livie-video--create :title (assoc-default 'title v) :author (assoc-default 'author v) :authorId (assoc-default 'authorId v) :length (assoc-default 'lengthSeconds v) :id (assoc-default 'videoId v) :views (assoc-default 'viewCount v) :published (assoc-default 'published v))))) videos)) (provide 'livie) ;;; livie.el ends here