diff options
| author | Craig Jennings <c@cjennings.net> | 2026-07-06 09:44:10 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-07-06 09:44:10 -0500 |
| commit | 348b1d62d40ab42697522552ba1e0cd98f007a5d (patch) | |
| tree | 66c8ac5923684f0cbfe99686131636c24552abad | |
| parent | dca4e105e20d9218277ad61bb9c7f3e199ef699f (diff) | |
| download | dotemacs-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.el | 43 | ||||
| -rw-r--r-- | tests/test-music-config--get-m3u-basenames.el | 21 | ||||
| -rw-r--r-- | tests/test-music-config--get-m3u-files.el | 25 | ||||
| -rw-r--r-- | tests/test-music-config--m3u-roots.el | 97 |
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 |
