;;; 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)