From 348b1d62d40ab42697522552ba1e0cd98f007a5d Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Mon, 6 Jul 2026 09:44:10 -0500 Subject: 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. --- modules/music-config.el | 43 ++++++++++++++++++++++++++++++++++++------- 1 file changed, 36 insertions(+), 7 deletions(-) (limited to 'modules') 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) -- cgit v1.2.3