aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-07-06 09:44:10 -0500
committerCraig Jennings <c@cjennings.net>2026-07-06 09:44:10 -0500
commit348b1d62d40ab42697522552ba1e0cd98f007a5d (patch)
tree66c8ac5923684f0cbfe99686131636c24552abad
parentdca4e105e20d9218277ad61bb9c7f3e199ef699f (diff)
downloaddotemacs-348b1d62d40ab42697522552ba1e0cd98f007a5d.tar.gz
dotemacs-348b1d62d40ab42697522552ba1e0cd98f007a5d.zip
feat(music): source M3U playlists from multiple directories
The MPD radio-stream playlists moved into version control at ~/.local/share/mpd/playlists/, while the 24 local-library playlists stay at ~/music/. MPD has a single playlist_directory and its load only sees that one, so the union has to happen at the Emacs layer. I added cj/music-m3u-roots, a precedence-ordered list of directories the reading path unions (get-m3u-files, basenames, select, load). Missing directories are skipped. On a basename collision the earlier root wins, so a local playlist shadows a same-named radio one. Saving and radio-station creation still target the single cj/music-m3u-root. Radio .m3u hold stream URLs, which the track reader and the mpv subprocess already handle, so nothing else changed to play them. The two tests that pinned the old single-directory contract now bind the list. Their "missing directory signals an error" cases became "missing directory is skipped" to match the new behavior.
-rw-r--r--modules/music-config.el43
-rw-r--r--tests/test-music-config--get-m3u-basenames.el21
-rw-r--r--tests/test-music-config--get-m3u-files.el25
-rw-r--r--tests/test-music-config--m3u-roots.el97
4 files changed, 155 insertions, 31 deletions
diff --git a/modules/music-config.el b/modules/music-config.el
index d16e2bb2..d5791eba 100644
--- a/modules/music-config.el
+++ b/modules/music-config.el
@@ -82,7 +82,19 @@
"Root directory of your music collection.")
(defvar cj/music-m3u-root cj/music-root
- "Directory where M3U playlists are saved and loaded.")
+ "Directory M3U playlists are saved to (the single writable target).
+Reading and selection union `cj/music-m3u-roots'; only saving and radio-station
+creation write here.")
+
+(defvar cj/music-m3u-roots
+ (list cj/music-root
+ (expand-file-name "~/.local/share/mpd/playlists/"))
+ "Directories to source M3U playlists from, in precedence order.
+Both the local-library playlists (`cj/music-root') and the dotfiles-tracked
+internet-radio playlists (MPD's playlist_directory) surface together for
+selection and loading. Earlier directories win on a basename collision.
+Missing directories are skipped. Saving still targets the single
+`cj/music-m3u-root'.")
(defvar cj/music-file-extensions '("aac" "flac" "m4a" "mp3" "ogg" "opus" "wav")
"List of valid music file extensions.")
@@ -279,13 +291,29 @@ modification date so marginalia can show them."
(forward-line 1))))
(nreverse tracks)))
+(defun cj/music--dedup-m3u-files (paths)
+ "Return (BASENAME . PATH) conses for PATHS, first occurrence of a basename winning.
+Pure helper: since `cj/music--get-m3u-files' scans `cj/music-m3u-roots' in order,
+an earlier directory shadows a same-named playlist in a later one."
+ (let ((seen (make-hash-table :test 'equal))
+ (result '()))
+ (dolist (p paths (nreverse result))
+ (let ((base (file-name-nondirectory p)))
+ (unless (gethash base seen)
+ (puthash base t seen)
+ (push (cons base p) result))))))
+
(defun cj/music--get-m3u-files ()
- "Return list of (BASENAME . FULLPATH) conses for M3Us in cj/music-m3u-root."
- (let ((files (directory-files cj/music-m3u-root t "\\.m3u\\'" t)))
- (mapcar (lambda (f) (cons (file-name-nondirectory f) f)) files)))
+ "Return (BASENAME . FULLPATH) conses for M3Us across `cj/music-m3u-roots'.
+Directories are scanned in order and missing ones skipped; on a basename
+collision the earlier directory wins."
+ (cj/music--dedup-m3u-files
+ (cl-loop for dir in cj/music-m3u-roots
+ when (file-directory-p dir)
+ append (directory-files dir t "\\.m3u\\'" t))))
(defun cj/music--get-m3u-basenames ()
- "Return list of M3U basenames (no extension) in cj/music-m3u-root."
+ "Return list of M3U basenames (no extension) across `cj/music-m3u-roots'."
(mapcar (lambda (pair) (file-name-sans-extension (car pair)))
(cj/music--get-m3u-files)))
@@ -312,11 +340,12 @@ Signals user-error if missing or deleted."
(file-name-nondirectory cj/music-playlist-file))))))
(defun cj/music--assert-m3u-files-exist ()
- "Assert that M3U files exist in cj/music-m3u-root.
+ "Assert that M3U files exist across `cj/music-m3u-roots'.
Returns the list of (BASENAME . FULLPATH) conses. Signals user-error if none."
(let ((files (cj/music--get-m3u-files)))
(when (null files)
- (user-error "No M3U files found in %s" cj/music-m3u-root))
+ (user-error "No M3U files found in %s"
+ (string-join cj/music-m3u-roots ", ")))
files))
(defun cj/music--sync-playlist-file (file-path)
diff --git a/tests/test-music-config--get-m3u-basenames.el b/tests/test-music-config--get-m3u-basenames.el
index 91c8af70..1e3875df 100644
--- a/tests/test-music-config--get-m3u-basenames.el
+++ b/tests/test-music-config--get-m3u-basenames.el
@@ -9,7 +9,7 @@
;; Test organization:
;; - Normal Cases: Multiple files, single file
;; - Boundary Cases: Empty directory, extension removal
-;; - Error Cases: Nonexistent directory
+;; - Error Cases: Nonexistent directory is skipped (not fatal)
;;
;;; Code:
@@ -47,7 +47,7 @@
(rename-file file2 (expand-file-name "jazz.m3u" test-dir))
(rename-file file3 (expand-file-name "classical.m3u" test-dir))
- (let ((cj/music-m3u-root test-dir))
+ (let ((cj/music-m3u-roots (list test-dir)))
(let ((result (cj/music--get-m3u-basenames)))
(should (= (length result) 3))
;; Sort for consistent comparison
@@ -63,7 +63,7 @@
(file1 (cj/create-temp-test-file-with-content "" "favorites.m3u")))
(rename-file file1 (expand-file-name "favorites.m3u" test-dir))
- (let ((cj/music-m3u-root test-dir))
+ (let ((cj/music-m3u-roots (list test-dir)))
(let ((result (cj/music--get-m3u-basenames)))
(should (= (length result) 1))
(should (equal (car result) "favorites")))))
@@ -76,7 +76,7 @@
(test-music-config--get-m3u-basenames-setup)
(unwind-protect
(let* ((test-dir (cj/create-test-subdirectory "empty-playlists")))
- (let ((cj/music-m3u-root test-dir))
+ (let ((cj/music-m3u-roots (list test-dir)))
(let ((result (cj/music--get-m3u-basenames)))
(should (null result)))))
(test-music-config--get-m3u-basenames-teardown)))
@@ -89,7 +89,7 @@
(file1 (cj/create-temp-test-file-with-content "" "test.m3u")))
(rename-file file1 (expand-file-name "playlist.m3u" test-dir))
- (let ((cj/music-m3u-root test-dir))
+ (let ((cj/music-m3u-roots (list test-dir)))
(let ((result (cj/music--get-m3u-basenames)))
(should (equal result '("playlist")))
;; Verify no .m3u extension present
@@ -104,18 +104,17 @@
(file1 (cj/create-temp-test-file-with-content "" "test.m3u")))
(rename-file file1 (expand-file-name "My Favorite Songs.m3u" test-dir))
- (let ((cj/music-m3u-root test-dir))
+ (let ((cj/music-m3u-roots (list test-dir)))
(let ((result (cj/music--get-m3u-basenames)))
(should (equal result '("My Favorite Songs"))))))
(test-music-config--get-m3u-basenames-teardown)))
;;; Error Cases
-(ert-deftest test-music-config--get-m3u-basenames-error-nonexistent-directory-signals-error ()
- "Nonexistent directory signals error."
- (let ((cj/music-m3u-root "/nonexistent/directory/path"))
- (should-error (cj/music--get-m3u-basenames)
- :type 'file-error)))
+(ert-deftest test-music-config--get-m3u-basenames-error-nonexistent-directory-skipped ()
+ "Nonexistent directories in the roots list are skipped, returning empty."
+ (let ((cj/music-m3u-roots '("/nonexistent/directory/path")))
+ (should-not (cj/music--get-m3u-basenames))))
(provide 'test-music-config--get-m3u-basenames)
;;; test-music-config--get-m3u-basenames.el ends here
diff --git a/tests/test-music-config--get-m3u-files.el b/tests/test-music-config--get-m3u-files.el
index 2d31d554..356735a9 100644
--- a/tests/test-music-config--get-m3u-files.el
+++ b/tests/test-music-config--get-m3u-files.el
@@ -4,12 +4,12 @@
;;
;;; Commentary:
;; Unit tests for cj/music--get-m3u-files function.
-;; Tests the helper that discovers M3U files in the music directory.
+;; Tests the helper that discovers M3U files across cj/music-m3u-roots.
;;
;; Test organization:
;; - Normal Cases: Multiple M3U files, single file
;; - Boundary Cases: Empty directory, non-M3U files, various filenames
-;; - Error Cases: Nonexistent directory
+;; - Error Cases: Nonexistent directory is skipped (not fatal)
;;
;;; Code:
@@ -48,7 +48,7 @@
(rename-file file2 (expand-file-name "playlist2.m3u" test-dir))
(rename-file file3 (expand-file-name "playlist3.m3u" test-dir))
- (let ((cj/music-m3u-root test-dir))
+ (let ((cj/music-m3u-roots (list test-dir)))
(let ((result (cj/music--get-m3u-files)))
(should (= (length result) 3))
;; Check structure: list of (basename . fullpath) conses
@@ -70,7 +70,7 @@
(file1 (cj/create-temp-test-file-with-content "" "myplaylist.m3u")))
(rename-file file1 (expand-file-name "myplaylist.m3u" test-dir))
- (let ((cj/music-m3u-root test-dir))
+ (let ((cj/music-m3u-roots (list test-dir)))
(let ((result (cj/music--get-m3u-files)))
(should (= (length result) 1))
(should (equal (caar result) "myplaylist.m3u"))
@@ -84,7 +84,7 @@
(test-music-config--get-m3u-files-setup)
(unwind-protect
(let* ((test-dir (cj/create-test-subdirectory "empty-playlists")))
- (let ((cj/music-m3u-root test-dir))
+ (let ((cj/music-m3u-roots (list test-dir)))
(let ((result (cj/music--get-m3u-files)))
(should (null result)))))
(test-music-config--get-m3u-files-teardown)))
@@ -101,7 +101,7 @@
(rename-file mp3-file (expand-file-name "song.mp3" test-dir))
(rename-file json-file (expand-file-name "data.json" test-dir))
- (let ((cj/music-m3u-root test-dir))
+ (let ((cj/music-m3u-roots (list test-dir)))
(let ((result (cj/music--get-m3u-files)))
(should (null result)))))
(test-music-config--get-m3u-files-teardown)))
@@ -114,7 +114,7 @@
(file1 (cj/create-temp-test-file-with-content "" "my-playlist.m3u")))
(rename-file file1 (expand-file-name "My Favorite Songs.m3u" test-dir))
- (let ((cj/music-m3u-root test-dir))
+ (let ((cj/music-m3u-roots (list test-dir)))
(let ((result (cj/music--get-m3u-files)))
(should (= (length result) 1))
(should (equal (caar result) "My Favorite Songs.m3u")))))
@@ -132,7 +132,7 @@
(rename-file txt-file (expand-file-name "readme.txt" test-dir))
(rename-file mp3-file (expand-file-name "song.mp3" test-dir))
- (let ((cj/music-m3u-root test-dir))
+ (let ((cj/music-m3u-roots (list test-dir)))
(let ((result (cj/music--get-m3u-files)))
(should (= (length result) 1))
(should (equal (caar result) "playlist.m3u")))))
@@ -140,11 +140,10 @@
;;; Error Cases
-(ert-deftest test-music-config--get-m3u-files-error-nonexistent-directory-signals-error ()
- "Nonexistent directory signals error."
- (let ((cj/music-m3u-root "/nonexistent/directory/path"))
- (should-error (cj/music--get-m3u-files)
- :type 'file-error)))
+(ert-deftest test-music-config--get-m3u-files-error-nonexistent-directory-skipped ()
+ "Nonexistent directories in the roots list are skipped, returning empty."
+ (let ((cj/music-m3u-roots '("/nonexistent/directory/path")))
+ (should-not (cj/music--get-m3u-files))))
(provide 'test-music-config--get-m3u-files)
;;; test-music-config--get-m3u-files.el ends here
diff --git a/tests/test-music-config--m3u-roots.el b/tests/test-music-config--m3u-roots.el
new file mode 100644
index 00000000..626415b6
--- /dev/null
+++ b/tests/test-music-config--m3u-roots.el
@@ -0,0 +1,97 @@
+;;; test-music-config--m3u-roots.el --- multi-directory M3U sourcing tests -*- coding: utf-8; lexical-binding: t; -*-
+;;
+;; Author: Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;; The player sources .m3u playlists from a LIST of directories
+;; (`cj/music-m3u-roots') so the local-library playlists (~/music) and the
+;; dotfiles-tracked internet-radio playlists (MPD's playlist_directory) surface
+;; together for selection and loading. Two pieces are tested:
+;;
+;; - `cj/music--dedup-m3u-files' — pure: turns a flat list of paths into
+;; (BASENAME . PATH) conses, first occurrence of a basename winning.
+;; - `cj/music--get-m3u-files' — unions the roots on disk, skips missing dirs,
+;; and applies the dedup so an earlier root shadows a same-named later one.
+;;
+;;; Code:
+
+(require 'ert)
+
+;; Stub missing dependencies before loading music-config.
+(defvar-keymap cj/custom-keymap
+ :doc "Stub keymap for testing")
+
+(require 'music-config)
+
+(declare-function cj/music--dedup-m3u-files "music-config" (paths))
+(declare-function cj/music--get-m3u-files "music-config" ())
+(defvar cj/music-m3u-roots)
+
+;;; --------------------------- cj/music--dedup-m3u-files ----------------------
+
+(ert-deftest test-music-config-dedup-m3u-distinct ()
+ "Normal: distinct basenames across dirs all appear, in order."
+ (should (equal (cj/music--dedup-m3u-files
+ '("/music/rhcp.m3u" "/radio/90s Sounds.m3u"))
+ '(("rhcp.m3u" . "/music/rhcp.m3u")
+ ("90s Sounds.m3u" . "/radio/90s Sounds.m3u")))))
+
+(ert-deftest test-music-config-dedup-m3u-single ()
+ "Normal: a single path yields a single cons."
+ (should (equal (cj/music--dedup-m3u-files '("/music/blues.m3u"))
+ '(("blues.m3u" . "/music/blues.m3u")))))
+
+(ert-deftest test-music-config-dedup-m3u-collision-first-wins ()
+ "Boundary: a basename in two dirs keeps the first path (earlier root wins)."
+ (should (equal (cj/music--dedup-m3u-files
+ '("/music/jazz.m3u" "/radio/jazz.m3u"))
+ '(("jazz.m3u" . "/music/jazz.m3u")))))
+
+(ert-deftest test-music-config-dedup-m3u-empty ()
+ "Boundary: an empty path list yields nil."
+ (should-not (cj/music--dedup-m3u-files '())))
+
+;;; ---------------------------- cj/music--get-m3u-files -----------------------
+
+(ert-deftest test-music-config-get-m3u-unions-roots ()
+ "Normal: M3Us from every existing root are unioned."
+ (let ((a (make-temp-file "m3u-a-" t))
+ (b (make-temp-file "m3u-b-" t)))
+ (unwind-protect
+ (progn
+ (write-region "" nil (expand-file-name "local.m3u" a))
+ (write-region "" nil (expand-file-name "radio.m3u" b))
+ (let* ((cj/music-m3u-roots (list a b))
+ (bases (mapcar #'car (cj/music--get-m3u-files))))
+ (should (member "local.m3u" bases))
+ (should (member "radio.m3u" bases))))
+ (delete-directory a t)
+ (delete-directory b t))))
+
+(ert-deftest test-music-config-get-m3u-skips-missing-root ()
+ "Error: a non-existent directory in the list is skipped, not fatal."
+ (let ((a (make-temp-file "m3u-a-" t)))
+ (unwind-protect
+ (progn
+ (write-region "" nil (expand-file-name "local.m3u" a))
+ (let* ((cj/music-m3u-roots (list a "/no/such/dir/here"))
+ (bases (mapcar #'car (cj/music--get-m3u-files))))
+ (should (equal bases '("local.m3u")))))
+ (delete-directory a t))))
+
+(ert-deftest test-music-config-get-m3u-collision-first-root-wins ()
+ "Boundary: same basename in two roots resolves to the earlier root's file."
+ (let ((a (make-temp-file "m3u-a-" t))
+ (b (make-temp-file "m3u-b-" t)))
+ (unwind-protect
+ (progn
+ (write-region "" nil (expand-file-name "jazz.m3u" a))
+ (write-region "" nil (expand-file-name "jazz.m3u" b))
+ (let* ((cj/music-m3u-roots (list a b))
+ (pair (assoc "jazz.m3u" (cj/music--get-m3u-files))))
+ (should (string-prefix-p a (cdr pair)))))
+ (delete-directory a t)
+ (delete-directory b t))))
+
+(provide 'test-music-config--m3u-roots)
+;;; test-music-config--m3u-roots.el ends here