livie/livie.el
2021-01-16 22:35:38 -05:00

281 lines
10 KiB
EmacsLisp

;;; livie.el --- Livie is Video in Emacs -*- lexical-binding: t; -*-
;; Copyright (C) 2018 - 2021
;;; Authors:
;; Charlie Ritter <chewzerita@posteo.net>
;; Jesus E. <heckyel@hyperbola.info>
;; Gabriele Rastello <gabriele.rastello@edu.unito.it>
;;; 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 "<tab>") #'next-line)
(define-key map (kbd "<backtab>") #'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 "<return>") '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