diff options
Diffstat (limited to 'livie.el')
-rw-r--r-- | livie.el | 475 |
1 files changed, 415 insertions, 60 deletions
@@ -7,17 +7,22 @@ ;; Charlie Ritter <chewzerita@posteo.net> ;; Jesus E. <heckyel@hyperbola.info> ;; Gabriele Rastello <gabriele.rastello@edu.unito.it> +;; Pablo BC <pablo.barraza@protonmail.com> ;;; 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) +(declare-function livie-channel 'livie-channel) +(declare-function livie--get-playlist-videos 'livie-playlist) + (defgroup livie '() "Livie is Video in Emacs" :prefix "livie-" @@ -29,18 +34,70 @@ :options '(relevance rating upload_date view_count) :group 'livie) +(defcustom livie-type-of-results "video" + "Set what type of results to get when making a search." + :type 'string + :options '("video" "playlist" "channel" "all") + :group 'livie) + +(defcustom livie-show-fancy-icons nil + "If t, enable showing fancy icons in the search buffer." + :type 'boolean + :group 'livie) + +;; TODO: Try to add support using all-the-icons, or add images instead. +(defcustom livie-icons '((video "Video" "✇") + (playlist "Playlist" "🎞") + ;; Added a space to this icon so everything is aligned + (channel "Channel" "📺 ") + (length "" "⌚:") + (views "views" "👁") + (subCount "subscribers" "🅯") + (videoCount "videos" "▶")) + "Icons for displaying items in buffer. First string is inserted if `livie-show-fancy-icons' is disabled." + :type '(alist :value-type (group string string)) + :group 'livie) + +(defvar livie--insert-functions '((video . livie--insert-video) + (playlist . livie--insert-playlist) + (channel . livie--insert-channel))) + +(defvar livie--default-action-functions '((video . livie--default-video-action) + (playlist . livie--default-playlist-action) + (channel . livie--default-channel-action)) + "Functions to call on an entry. To modify an action, set the appropiate variable instead.") + +(defvar livie--default-video-action #'(lambda () + (message (livie-video-title (livie-get-current-video)))) + "Action to open a video. By default it just prints the title to the minibuffer.") + +(defvar livie--default-playlist-action #'livie--open-playlist + "Action to open a playlist.") + +(defvar livie--default-channel-action #'livie--open-channel + "Action to open a channel.") + + (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" +(defvar livie-default-video-query-fields "type,author,lengthSeconds,title,videoId,authorId,viewCount,published" "Default fields of interest for video search.") +(defvar livie-default-channel-query-fields "type,author,authorId,subCount,videoCount" + "Default fields of interest for channel search.") + +(defvar livie-default-playlist-query-fields "type,title,playlistId,author,authorId,videoCount" + "Default fields of interest for playlist search.") + (defvar livie-videos '() "List of videos currently on display.") -(defvar livie-published-date-time-string "%Y-%m-%d" +(defcustom 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.") +See `format-time-string' for information on how to edit this variable." + :type 'string + :group 'livie) (defvar-local livie-current-page 1 "Current page of the current `livie-search-term'") @@ -48,15 +105,33 @@ See `format-time-string' for information on how to edit this variable.") (defvar-local livie-search-term "" "Current search string as used by `livie-search'") -(defvar livie-author-name-reserved-space 20 +(defcustom 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).") +too long)." + :type 'integer + :group 'livie) -(defvar livie-title-video-reserved-space 100 +(defcustom 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).") +too long)." + :type 'integer + :group 'livie) + +(defcustom livie-title-playlist-reserved-space 30 + "Number of characters reserved for the playlist title in the *livie* buffer. +Note that there will always 3 extra spaces for eventual dots (for names that are +too long)." + :type 'integer + :group 'livie) + +(defcustom livie-name-channel-reserved-space 50 + "Number of characters reserved for the channel name in the *livie* buffer. +Note that there will always 3 extra spaces for eventual dots (for names that are +too long)." + :type 'integer + :group 'livie) (defface livie-video-published-face '((((class color) (background light)) (:foreground "#00C853")) @@ -78,6 +153,23 @@ too long).") (((class color) (background dark)) (:foreground "#00BFA5"))) "Face used for the video views.") +(defface livie-video-title-face + '((((class color) (background light)) (:foreground "#000000")) + (((class color) (background dark)) (:foreground "#FFFFFF"))) + "Face used for the video title.") + +(defface livie-item-videoCount-face + '((t :inherit livie-video-view-face)) + "Face used for the videoCount of an entry.") + +(defface livie-item-subCount-face + '((t :inherit livie-video-published-face)) + "Face used for the subCount of an entry.") + +(defface livie-parameter-face + '((t :inherit livie-video-published-face)) + "Face used for the parameters of the current search.") + (defvar livie-mode-map (let ((map (make-sparse-keymap))) (suppress-keymap map) @@ -90,12 +182,22 @@ too long).") (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) + (define-key map "t" #'livie-search-type) + (define-key map "S" #'livie-sort-videos) + (define-key map "C" #'livie-show-channels) + (define-key map "P" #'livie-show-playlists) + (define-key map "V" #'livie-show-videos) + (define-key map "Y" #'livie-yank-channel-feed) + (define-key map "A" #'livie--open-channel) + (define-key map (kbd "RET") #'livie-open-entry) + (define-key map "y" #'livie-watch-this-video) map) "Keymap for `livie-mode'.") (define-derived-mode livie-mode text-mode "livie-mode" + "A major mode to query Youtube content through Invidious." + :group 'livie (setq buffer-read-only t) (buffer-disable-undo) (make-local-variable 'livie-videos)) @@ -113,7 +215,7 @@ too long).") (concat name (make-string (abs extra-chars) ?\ ) " ") - (concat (seq-subseq name 0 livie-author-name-reserved-space) + (concat (truncate-string-to-width name livie-author-name-reserved-space) "...")))) (propertize formatted-string 'face 'livie-channel-name-face))) @@ -125,13 +227,38 @@ too long).") (concat title (make-string (abs extra-chars) ?\ ) " ") - (concat (seq-subseq title 0 livie-title-video-reserved-space) + (concat (truncate-string-to-width title livie-title-video-reserved-space) + "...")))) + (propertize formatted-string 'face 'livie-video-title-face))) + +(defun livie--format-playlist-title (title) + "Format a playlist TITLE to be inserted in the *livie* buffer." + (let* ((n (string-width title)) + (extra-chars (- n livie-title-playlist-reserved-space)) + (formatted-string (if (<= extra-chars 0) + (concat title + (make-string (abs extra-chars) ?\ ) + " ") + (concat (truncate-string-to-width title livie-title-playlist-reserved-space) + "...")))) + (propertize formatted-string 'face 'livie-video-title-face))) + +(defun livie--format-channel-name (name) + "Format a channel NAME to be inserted in the *livie* buffer." + (let* ((n (string-width name)) + (extra-chars (- n livie-name-channel-reserved-space)) + (formatted-string (if (<= extra-chars 0) + (concat name + (make-string (abs extra-chars) ?\ ) + " ") + (concat (truncate-string-to-width name livie-name-channel-reserved-space) "...")))) - formatted-string)) + (propertize formatted-string 'face 'livie-channel-name-face))) (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) + (let ((formatted-string (concat (livie--get-icon 'length) + (format-seconds "%.2h" seconds) ":" (format-seconds "%.2m" (mod seconds 3600)) ":" @@ -140,52 +267,123 @@ too long).") (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)) + (propertize (format "[%s: %d]" (livie--get-icon 'views) 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--format-videoCount (videoCount) + "Format video VIDEOCOUNT to be inserted in the *livie* buffer." + (propertize (format "[%s: %d]" (livie--get-icon 'videoCount) videoCount) 'face 'livie-item-videoCount-face)) + +(defun livie--format-subCount (subCount) + "Format video SUBCOUNT to be inserted in the *livie* buffer." + (propertize (format "%s: %-10d" (livie--get-icon 'subCount) subCount) 'face 'livie-item-subCount-face)) + +(defun livie--format-type (type) + "Insert an icon of TYPE into buffer." + (if livie-show-fancy-icons + (propertize (format "%-2s: " (livie--get-icon type)) 'face 'livie-video-title-face) + (propertize (format "%-10s: " (livie--get-icon type)) 'face 'livie-video-title-face))) + +(defun livie--get-icon (item) + "Get the icon for ITEM from `livie-icons'." + (let* ((getmarks (assoc-default item livie-icons))) + (if livie-show-fancy-icons + (when (fboundp 'second) + (second getmarks)) + (car getmarks)))) + +(defun livie--insert-entry (entry) + "Insert an ENTRY of the form according to its type." + (let* ((type (if (not (equal livie-type-of-results "all")) + (intern livie-type-of-results) + (cond ((livie-video-p entry) 'video) + ((livie-playlist-p entry) 'playlist) + ((livie-channel-p entry) 'channel) + (t (error "Invalid entry type"))))) + (func (cdr (assoc type livie--insert-functions)))) + (when (equal livie-type-of-results "all") + (insert (livie--format-type type))) + (funcall func entry))) + (defun livie--insert-video (video) - "Insert `VIDEO' in the current buffer." - (insert (livie--format-video-published (livie-video-published 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)))) + + ;TODO: Format playlist and channel entries in buffer +(defun livie--insert-playlist (playlist) + "Insert PLAYLIST in the current buffer." + (insert (livie--format-playlist-title (livie-playlist-title playlist)) " " - (livie--format-author (livie-video-author video)) + (livie--format-author (livie-playlist-author playlist)) " " - (livie--format-video-length (livie-video-length video)) + (livie--format-videoCount (livie-playlist-videoCount playlist)))) + +(defun livie--insert-channel (channel) + "Insert CHANNEL in the current buffer." + (insert (livie--format-channel-name (livie-channel-author channel)) " " - (livie--format-title (livie-video-title video)) + (livie--format-subCount (livie-channel-subCount channel)) " " - (livie--format-video-views (livie-video-views video)))) + (livie--format-videoCount (livie-channel-videoCount channel)))) (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) + (setf header-line-format (concat (propertize (capitalize livie-type-of-results) 'face 'livie-parameter-face) + " results for " + (propertize livie-search-term 'face 'livie-parameter-face) ", page " - (number-to-string livie-current-page))) + (propertize (number-to-string livie-current-page) 'face 'livie-parameter-face) + ", sorted by: " + (propertize (symbol-name livie-sort-criterion) 'face 'livie-parameter-face))) (seq-do (lambda (v) - (livie--insert-video v) + (livie--insert-entry v) (insert "\n")) livie-videos) (goto-char (point-min)))) +(defun livie-enable-fancy-icons () + "Enable fancy icons in the *livie* buffer, using `livie-icons'." + (interactive) + (setf livie-show-fancy-icons t)) + +(defun livie-disable-fancy-icons () + "Disable fancy icons in the *livie* buffer, using `livie-icons'." + (interactive) + (setf livie-show-fancy-icons nil)) + +(defun livie-toggle-fancy-icons () + "Toggle display of fancy-icons in the *livie* buffer, using `livie-icons'." + (interactive) + (setf livie-show-fancy-icons (not livie-show-fancy-icons))) + (defun livie-search (query) "Search youtube for `QUERY', and redraw the buffer." (interactive "sSearch: ") + (switch-to-buffer "*livie*") (setf livie-current-page 1) (setf livie-search-term query) - (setf livie-videos (livie--query query livie-current-page)) + (setf livie-videos (livie--process-results (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-videos (livie--process-results (livie--query livie-search-term + (1+ livie-current-page)))) (setf livie-current-page (1+ livie-current-page)) (livie--draw-buffer)) @@ -193,11 +391,63 @@ too long).") "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-videos (livie--process-results (livie--query livie-search-term + (1- livie-current-page)))) (setf livie-current-page (1- livie-current-page)) (livie--draw-buffer))) +(defun livie-search-type (&optional arg) + "Ask for what type of results to display, and search. +If ARG is given, make a new search." + (interactive "P") + (when arg + (setf livie-search-term (read-string "Search: "))) + (setf livie-current-page 1) + (setf livie-type-of-results (completing-read "Show: " (get 'livie-type-of-results 'custom-options))) + (setf livie-videos (livie--process-results (livie--query livie-search-term livie-current-page))) + (livie--draw-buffer)) + +(defun livie-show-videos (&optional arg) + "Show videos for the current search. +If ARG is given, make a new search." + (interactive "P") + (when arg + (setf livie-search-term (read-string "Search: "))) + (setf livie-current-page 1) + (setf livie-type-of-results "video") + (setf livie-videos (livie--process-results (livie--query livie-search-term livie-current-page))) + (livie--draw-buffer)) + +(defun livie-show-channels (&optional arg) + "Show channels for the current search. +If ARG is given, make a new search." + (interactive "P") + (when arg + (setf livie-search-term (read-string "Search: "))) + (setf livie-current-page 1) + (setf livie-type-of-results "channel") + (setf livie-videos (livie--process-results (livie--query livie-search-term livie-current-page))) + (livie--draw-buffer)) + +(defun livie-show-playlists (&optional arg) + "Show playlists for the current search. +If ARG is given, make a new search." + (interactive "P") + (when arg + (setf livie-search-term (read-string "Search: "))) + (setf livie-current-page 1) + (setf livie-type-of-results "playlist") + (setf livie-videos (livie--process-results (livie--query livie-search-term livie-current-page))) + (livie--draw-buffer)) + +(defun livie-sort-videos () + "Sort videos from the current search from page 1, according to values of `livie-sort-criterion'." + (interactive) + (setf livie-sort-criterion (intern (completing-read "Sort videos by (default value is relevance): " (get 'livie-sort-criterion 'custom-options)))) + (setf livie-current-page 1) + (setf livie-videos (livie--process-results (livie--query livie-search-term livie-current-page))) + (livie--draw-buffer)) + (defun livie-get-current-video () "Get the currently selected video." (aref livie-videos (1- (line-number-at-pos)))) @@ -205,14 +455,55 @@ too long).") (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...")) + (if (equal (livie--get-entry-type (livie-get-current-video)) 'video) + (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")) + (message "Starting streaming...")) + (message "It's not a video"))) + +(defun livie-yank-channel-feed (&optional arg) + "Yank channel's Youtube RSS feed for the current video at point. +If ARG is given, format it as a Invidious RSS feed." + (interactive "P") + (let* ((entry (livie-get-current-video)) + (author (funcall (livie--get-author-function entry) entry)) + (authorId (funcall (livie--get-authorId-function entry) entry)) + (url (if arg + (concat livie-invidious-api-url "/feed/channel/" authorId) + (concat "https://www.youtube.com/feeds/videos.xml?channel_id=" authorId)))) + (kill-new url) + (message "Copied RSS feed for: %s - %s" author url))) + +(defun livie--get-entry-type (entry) + "Return the type of ENTRY." + (if (not (equal livie-type-of-results "all")) + (intern livie-type-of-results) + (cond ((livie-video-p entry) 'video) + ((livie-playlist-p entry) 'playlist) + ((livie-channel-p entry) 'channel) + (t (error "Invalid entry type"))))) + +(defun livie--get-author-function (entry) + "Get the author for ENTRY." + (let* ((type (livie--get-entry-type entry))) + (pcase type + ('video #'livie-video-author) + ('playlist #'livie-playlist-author) + ('channel #'livie-channel-author) + (_ (error "Invalid entry type"))))) + +(defun livie--get-authorId-function (entry) + "Get the author for ENTRY." + (let* ((type (livie--get-entry-type entry))) + (pcase type + ('video #'livie-video-authorId) + ('playlist #'livie-playlist-authorId) + ('channel #'livie-channel-authorId) + (_ (error "Invalid entry type"))))) (defun livie-buffer () "Name for the main livie buffer." @@ -240,40 +531,104 @@ too long).") (views 0 :read-only t) (published 0 :read-only t)) +;; Maybe type should be part of the struct. +(cl-defstruct (livie-channel (:constructor livie-channel--create) + (:copier nil)) + "Information about a Youtube channel." + (author "" :read-only t) + (authorId "" :read-only t) + (subCount 0 :read-only t) + (videoCount 0 :read-only t)) + +(cl-defstruct (livie-playlist (:constructor livie-playlist--create) + (:copier nil)) + "Information about a Youtube playlist." + (title "" :read-only t) + (playlistId "" :read-only t) + (author "" :read-only t) + (authorId "" :read-only t) + (videoCount 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))))) + (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")) + (error "Curl had problems connecting to Invidious API")) (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)) +"Query youtube for STRING, return the Nth page of results." +(let ((results (livie--API-call "search" `(("q" ,string) + ("sort_by" ,(symbol-name livie-sort-criterion)) + ("type" ,livie-type-of-results) + ("page" ,n) + ("fields" ,(pcase livie-type-of-results + ("video" livie-default-video-query-fields) + ("playlist" livie-default-playlist-query-fields) + ("channel" livie-default-channel-query-fields) + ;; I mean, it does get the job done... fix later. + ("all" (concat livie-default-channel-query-fields + "," + livie-default-playlist-query-fields + "," + livie-default-video-query-fields)))))))) + results)) + +(defun livie--process-results (results &optional type) + "Process RESULTS and turn them into objects, is TYPE is not given, get it from RESULTS." + (dotimes (i (length results)) + (let* ((v (aref results i)) + (type (or type (assoc-default 'type v)))) + (aset results i (pcase type + ("video" (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))) + ("playlist" (livie-playlist--create + :title (assoc-default 'title v) + :playlistId (assoc-default 'playlistId v) + :author (assoc-default 'author v) + :authorId (assoc-default 'authorId v) + :videoCount (assoc-default 'videoCount v))) + ("channel" (livie-channel--create + :author (assoc-default 'author v) + :authorId (assoc-default 'authorId v) + :subCount (assoc-default 'subCount v) + :videoCount (assoc-default 'videoCount v))))))) + results) + +(defun livie-open-entry () + "Open the entry at point depending on it's type." + (interactive) + (let* ((entry (livie-get-current-video)) + (type (livie--get-entry-type entry))) + (funcall (symbol-value (assoc-default type livie--default-action-functions))))) + +(defun livie--open-channel () + "Fetch the channel page for the entry at point." + (interactive) + (require 'livie-channel) + (livie-channel)) + +(defun livie--open-playlist () + "Open the contents of the entry at point, if it's a playlist." + (interactive) + (require 'livie-playlist) + (livie--get-playlist-videos)) (provide 'livie) |