summaryrefslogtreecommitdiff
path: root/movie-grid.el
diff options
context:
space:
mode:
authorskylar <me@casper>2026-11-01 07:03:26 -0800
committerskylar <me@casper>2026-11-01 07:03:26 -0800
commit070a107873440d62b4519e6d91c2ba739d4b6fa2 (patch)
treeae4e1c3540cdd01b3289ed6f96ec3ea69507f73e /movie-grid.el
Literate Doom Emacs configuration
config.org tangles to config.el, which loads private/config.el last when it exists. bin/ holds the scripts some sections drive. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'movie-grid.el')
-rw-r--r--movie-grid.el208
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)