diff options
Diffstat (limited to 'movie-grid.el')
| -rw-r--r-- | movie-grid.el | 208 |
1 files changed, 208 insertions, 0 deletions
diff --git a/movie-grid.el b/movie-grid.el new file mode 100644 index 0000000..96aeecd --- /dev/null +++ b/movie-grid.el @@ -0,0 +1,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) |