aboutsummaryrefslogtreecommitdiffstats
path: root/livie.el
blob: 50392478209186f18455967034558595969b542d (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
;;; 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