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
|
;;; movie-grid.el -*- lexical-binding: t; -*-
;; A grid of movie posters pulled from Radarr, built on top of
;; `image-dired-thumbnail-mode' the same way `my/media-thumbnails' is:
;; each poster is inserted as a thumbnail whose "original file" is the
;; movie's actual video file rather than the poster itself, so the
;; existing `my/media--play-video-a' advice (which already fires on RET,
;; SPC, C-RET, ...) sees a video extension and hands it straight to mpv
;; instead of trying to display it as an image. Grid navigation, "q" to
;; close back to the grid, and everything else about the thumbnail
;; buffer is exactly what `my/media-thumbnails' already gets for free.
(defcustom my/movie-grid-radarr-url "http://localhost:7878"
"Base URL of the Radarr instance backing `my/movie-grid'."
:type 'string)
(defcustom my/movie-grid-radarr-api-key-entry "radarr"
"Password-store entry holding the Radarr API key.
Found in Radarr under Settings > General > Security. Add it with
\"pass insert radarr\"."
:type 'string)
(defvar my/movie-grid--radarr-api-key nil
"Cached Radarr API key, fetched from `password-store' on first use.")
(defun my/movie-grid--radarr-api-key ()
"Return the Radarr API key, fetching it from `password-store' once."
(or my/movie-grid--radarr-api-key
(setq my/movie-grid--radarr-api-key
(or (password-store-get my/movie-grid-radarr-api-key-entry)
(user-error "No API key in password-store entry %S; run \"pass insert %s\""
my/movie-grid-radarr-api-key-entry
my/movie-grid-radarr-api-key-entry)))))
(defun my/movie-grid--radarr-get (path)
"Return Radarr's JSON response for PATH, parsed into plists."
(with-temp-buffer
(unless (zerop (call-process "curl" nil t nil "-sf"
"-H" (concat "X-Api-Key: " (my/movie-grid--radarr-api-key))
(concat my/movie-grid-radarr-url path)))
(error "Radarr request to %s failed; is it running at %s?"
path my/movie-grid-radarr-url))
(goto-char (point-min))
(json-parse-buffer :object-type 'plist :array-type 'list :false-object :false)))
(defun my/movie-grid--poster-thumbnail-file (movie)
"Return a cached, thumbnail-sized poster image file for MOVIE.
Downloads and resizes it on first use; later calls reuse the cached
file, same as `my/media--video-thumbnail-file' does for video frames."
(let* ((id (plist-get movie :id))
(cache (expand-file-name "movie-posters" (image-dired-dir)))
(thumb (expand-file-name (format "%d.jpg" id) cache)))
(make-directory cache t)
(unless (file-exists-p thumb)
(let ((raw (make-temp-file "movie-poster-" nil ".jpg")))
(unwind-protect
(if (and (zerop (call-process
"curl" nil nil nil "-sf"
"-H" (concat "X-Api-Key: " (my/movie-grid--radarr-api-key))
"-o" raw
(format "%s/api/v3/mediacover/%d/poster.jpg"
my/movie-grid-radarr-url id)))
(zerop (call-process
"convert" nil nil nil raw "-resize"
(format "%dx%d" image-dired-thumb-size (* 2 image-dired-thumb-size))
thumb)))
nil
(message "movie-grid: failed to fetch poster for %s" (plist-get movie :title)))
(delete-file raw))))
(and (file-exists-p thumb) thumb)))
(defun my/movie-grid--movies ()
"Return Radarr's movies that have a file on disk, sorted by title."
(let ((movies (seq-filter (lambda (m) (eq (plist-get m :hasFile) t))
(my/movie-grid--radarr-get "/api/v3/movie"))))
(sort movies (lambda (a b) (string-lessp (plist-get a :sortTitle)
(plist-get b :sortTitle))))))
;; `image-dired-line-up'/`image-dired-line-up-dynamic' can't be reused as-is
;; once a title needs to go under each poster: they lay out a flat run of
;; [image][space][image][space]... and, critically, get there by deleting
;; every non-image character first - any title text inserted after a
;; thumbnail would just be stripped back out on the next line-up pass. So
;; the grid below does its own row-by-row layout instead, reusing only the
;; column-width arithmetic `image-dired-line-up' itself uses for
;; `:align-to', so poster columns and the title row underneath line up.
;; Motion still comes entirely from image-dired: `image-dired-forward-image'
;; and friends just walk character by character until they find something
;; with the image-dired-thumbnail property, so a title line's worth of
;; plain text in between is skipped exactly like the usual one-space
;; separator is, and `image-dired-next-line'/`previous-line' (column-
;; preserving line motion) work the same as always since each row is still
;; the two buffer lines image-dired expects a row to advance by: images,
;; then titles.
(defun my/movie-grid--thumb-width-chars ()
"Column width of one poster cell, matching `image-dired-line-up'."
(ceiling (/ (+ (* 2 image-dired-thumb-relief)
(* 2 image-dired-thumb-margin)
(image-dired--thumb-size))
(float (frame-char-width)))))
(defun my/movie-grid--columns (thumb-width-chars)
"Number of poster columns that fit the window, like `image-dired-line-up-dynamic'."
(max 1 (/ (window-body-width (image-dired-thumbnail-window) t)
(* thumb-width-chars (frame-char-width)))))
(defun my/movie-grid--insert-row (movies col-width)
"Insert one row of MOVIES: a line of posters, then a line of titles.
Both lines align to the same COL-WIDTH-wide columns. The title text is
plain, searchable text - not itself an image-dired thumbnail, so it
doesn't become an extra stop for `image-dired-forward-image' and
friends - but each title carries a `movie-grid-image-position'
property pointing back at its own poster's buffer position, so RET
and C-RET on the title (e.g. after landing there via evil's \"/\")
still resolve to the right movie; see
`my/movie-grid--snap-to-thumbnail-a'."
(let (positions)
(dotimes (col (length movies))
(let* ((movie (nth col movies))
(video (plist-get (plist-get movie :movieFile) :path))
(thumb (my/movie-grid--poster-thumbnail-file movie)))
(push (point) positions)
(when (and video thumb)
(image-dired-insert-thumbnail thumb video nil))
(insert (propertize " " 'display `(space :align-to ,(* (1+ col) col-width))))))
(setq positions (nreverse positions))
(insert "\n")
(dotimes (col (length movies))
(let* ((movie (nth col movies))
(title (plist-get movie :title)))
(insert (propertize (truncate-string-to-width title (1- col-width) nil nil "…")
'face 'image-dired-thumb-header-file-name
'movie-grid-image-position (nth col positions)))
(insert (propertize " " 'display `(space :align-to ,(* (1+ col) col-width))))))
(insert "\n\n")))
;; Landing on a title via evil's native "/" search is the whole point of
;; having titles at all, but RET (`image-dired-display-this') and C-RET
;; (`image-dired-thumbnail-display-external') both refuse outright unless
;; `image-dired-image-at-point-p' is true first, before either gets anywhere
;; near `image-dired-original-file-name' - so landing on title text and
;; pressing RET always fails with "No thumbnail at point", regardless of
;; what properties the title carries. Move point onto the linked poster
;; first, whenever it's there to move to.
(defun my/movie-grid--snap-to-thumbnail-a (orig-fn &rest args)
"Move point onto the poster for the title at point before calling ORIG-FN."
(when-let* (((not (image-dired-image-at-point-p)))
(pos (get-text-property (point) 'movie-grid-image-position)))
(goto-char pos))
(apply orig-fn args))
(advice-add #'image-dired-display-this :around #'my/movie-grid--snap-to-thumbnail-a)
(advice-add #'image-dired-thumbnail-display-external :around #'my/movie-grid--snap-to-thumbnail-a)
;;;###autoload
(defun my/movie-grid ()
"Show a grid of movie posters from Radarr, with titles underneath.
Uses `image-dired-thumbnail-mode', so the normal image-dired motion
keys move around the grid, and RET/SPC/C-RET play the movie under
point with mpv via the same advice `my/media-thumbnails' relies on for
video files. Evil's \"/\" searches the titles directly, same as any
other text in the buffer."
(interactive)
(require 'image-dired)
(unless (executable-find "convert")
(user-error "ImageMagick's convert is not installed"))
(let* ((movies (my/movie-grid--movies))
(buf (image-dired-create-thumbnail-buffer)))
(unless movies
(user-error "No movies with files found in Radarr"))
;; Pop to the buffer before laying anything out: column count depends
;; on the window this buffer is showing in, via
;; `image-dired-thumbnail-window', which only resolves to something
;; once the buffer is actually displayed somewhere.
(pop-to-buffer buf)
(let ((inhibit-read-only t)
(remaining movies)
col-width columns)
(erase-buffer)
(setq-local image-dired--number-of-thumbnails (length movies))
;; Every thumbnail here has a nil associated-dired-buffer (there's no
;; single flat Dired listing that maps onto a nested
;; movies-in-their-own-folders layout), but `image-dired-forward-image'
;; and friends unconditionally try to mirror point into that buffer
;; after every move when this is on, and error when it isn't there.
;; The feature itself - keeping a split-window Dired in sync - has
;; nothing to sync with here anyway, so just turn it off.
(setq-local image-dired-track-movement nil)
(setq col-width (my/movie-grid--thumb-width-chars)
columns (my/movie-grid--columns col-width))
(while remaining
(my/movie-grid--insert-row (seq-take remaining columns) col-width)
(setq remaining (seq-drop remaining columns))))
(goto-char (point-min))
(image-dired--update-header-line)
;; `evil-collection''s image-dired bindings (the ones that make motion
;; skip the title/separator text instead of stopping on it) only apply
;; in normal state. `image-dired-thumbnail-mode' buffers should always
;; be in normal state - `set-evil-initial-state!' in the Media
;; thumbnails section says so - but that only fires the first time the
;; major mode is set on a buffer, not on later reuses of this same
;; buffer, so a state that slipped once (e.g. entered from a visual
;; selection elsewhere) would stick around on every subsequent call.
(when (fboundp 'evil-normal-state)
(evil-normal-state))))
(provide 'movie-grid)
|