feat: dynamic directory playlists via [[dir]] sources (#308)
* feat(tomlutil): add ParseNamedSections for multi-section documents * feat(resolve): add AudioFiles and TracksFromPaths helpers * feat(playlist): add DirSourced flag to Track * feat(local): support [[dir]] directory sources in playlists Playlists can now reference directories with [[dir]] sections instead of listing every track. Directory sources are scanned at load time, so new files appear and removed files disappear automatically. - parsePlaylistDoc keeps explicit tracks and dir sources in document order - expand resolves dirs into tracks, marking them DirSourced; explicit [[track]] entries always shadow a directory scan of the same path - savePlaylist preserves [[dir]] sections and skips DirSourced tracks - bookmarking a dir-sourced track materializes it as an explicit entry so the bookmark persists - RemoveTrack refuses dir-sourced tracks; AddTracks dedupes against them - Playlists()/SearchTracks operate on the expanded view - CreateDirPlaylist, AddDirSource (deduped), DirSources added * feat(cli): add --dir flags and playlist dirs subcommand playlist create and add accept repeatable --dir flags that reference a directory as a [[dir]] source, and a new 'playlist dirs' subcommand lists them. --dir cannot be combined with --ssh. enrich skips dir-sourced tracks and sort notes that they reload in scan order. * feat(ui): guard edits on dir-sourced playlist tracks * docs: document [[dir]] directory sources * docs: show playlist file layout and multi-file pickup * docs: show adding files/directories across one or many playlists * fix: address code review findings for directory playlists - Save playlists with interleaved [[track]]/[[dir]] section order instead of flattening dirs first, so removals, reorders, enrichment, and bookmark materialization keep each section's original position. - Remove UI tracks by matching the persisted explicit track by path, so a rescan between load and save cannot remove the wrong track. - Render playlist documents in memory before the atomic rename so a short write can never truncate an existing playlist. - Validate all inputs (audio paths, directory sources) before persisting: create and add fail without leaving partially-written playlists behind. - Persist directory sources as one atomic batch (AddDirSources). - Skip unreadable entries during recursive directory scans instead of aborting the whole scan. - Wrap directory operations with contextual errors; document directory sources on the site. - Regression tests for section-order preservation, atomic batch validation, no-partial-playlist-on-failure, and unreadable-subdir scans. * fix: resolve remaining code review findings - tomlutil: flush and clear state on unrecognized array-table headers so fields cannot leak into the previous section - local: propagate playlist read errors instead of rewriting the file without its [[dir]] sections - cmd: use plural helpers for the created-playlist message and wrap playlist load errors in playlist bookmark - resolve: wrap filesystem errors with operation context - docs: describe the .toml discovery rule accurately and label the directory-tree fence * fix: keep leftover track insertion positions aligned Two leftovers materialized in one save could land in the wrong slot: each insertion shifts later sections, so directory positions tracked in dirPos must be re-aligned after every insertion. Replace the supplier scan with a pure path check so saves never re-walk the filesystem, and skip the unreadable-subdir test on Windows where os.Chmod maps to the read-only attribute instead of Unix permissions. * fix: persist cross-playlist tracks as explicit entries A track added from a directory-backed playlist carried its DirSourced flag into the destination playlist. savePlaylist then dropped it (the destination has no owning [[dir]] section), so the track was reported as added but silently lost. Clear the flag on incoming tracks in the AddTracks merge. Clarify that the playlist listing omits unknown durations (browser already hides them) and still walks directory sources to count files. * fix: only treat supported audio files as dir-supplied dirSuppliesFile now validates the candidate extension against player.SupportedExts before the path-containment checks, so non-audio files added as explicit tracks (e.g. cover.jpg under a [[dir]]) are appended at the end instead of being inserted before the directory section. * test: table-driven coverage for dirSuppliesFile predicate * test: fix Windows path assertions in dir tests - Normalize ExpandPath's env-expanded result with filepath.Clean before comparing: on Windows the raw expansion mixes / and \ separators. - Assert TestSavePlaylistPreservesDirsAndSkipsDirTracks against the parsed document instead of raw text: the writer escapes backslashes via %q, so substring matching of a Windows temp path never matched.
This commit is contained in:
@@ -2143,6 +2143,12 @@ func (m *Model) plMgrRemoveSelectedTracks() {
|
||||
if len(indices) == 0 {
|
||||
return
|
||||
}
|
||||
for _, i := range indices {
|
||||
if m.plManager.tracks[i].DirSourced {
|
||||
m.status.Showf(statusTTLDefault, "Can't remove %q: it's supplied by the playlist's directory source", m.plManager.tracks[i].DisplayName())
|
||||
return
|
||||
}
|
||||
}
|
||||
m.plMgrSetTrackUndo()
|
||||
for i := len(indices) - 1; i >= 0; i-- {
|
||||
idx := indices[i]
|
||||
@@ -2245,10 +2251,21 @@ func (m *Model) persistLoadedPlaylistOrder() {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
hasDirTracks := false
|
||||
for _, t := range m.playlist.Tracks() {
|
||||
if t.DirSourced {
|
||||
hasDirTracks = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if err := saver.SavePlaylist(m.loadedPlaylist, m.playlist.Tracks()); err != nil {
|
||||
m.status.Showf(statusTTLDefault, "Save failed: %s", err)
|
||||
return
|
||||
}
|
||||
if hasDirTracks {
|
||||
m.status.Showf(statusTTLDefault, "Reordered %q (directory-sourced tracks keep scan order)", m.loadedPlaylist)
|
||||
return
|
||||
}
|
||||
m.status.Showf(statusTTLDefault, "Reordered %q", m.loadedPlaylist)
|
||||
}
|
||||
|
||||
|
||||
+17
-3
@@ -191,6 +191,10 @@ func (m *Model) removeSelectedFromPlaylist() {
|
||||
}
|
||||
snapshot := m.playlist.Snapshot()
|
||||
track := m.playlist.Tracks()[idx]
|
||||
if track.DirSourced {
|
||||
m.status.Showf(statusTTLDefault, "Can't remove %q: it's supplied by the playlist's directory source", track.DisplayName())
|
||||
return
|
||||
}
|
||||
loaded := m.loadedPlaylist
|
||||
var saved []playlist.Track
|
||||
persisted := false
|
||||
@@ -202,12 +206,22 @@ func (m *Model) removeSelectedFromPlaylist() {
|
||||
m.status.Showf(statusTTLDefault, "Remove failed: %s", err)
|
||||
return
|
||||
}
|
||||
if idx >= len(saved) {
|
||||
m.status.Showf(statusTTLDefault, "Remove failed: selected track is not in %q", loaded)
|
||||
// saved rescans directory sources, so a new file could have shifted
|
||||
// indexes since the queue was loaded. Match the persisted explicit
|
||||
// track by path so the wrong track is never removed.
|
||||
savedIdx := -1
|
||||
for i, candidate := range saved {
|
||||
if !candidate.DirSourced && candidate.Path == track.Path {
|
||||
savedIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if savedIdx < 0 {
|
||||
m.status.Showf(statusTTLDefault, "Remove failed: selected track is no longer in %q", loaded)
|
||||
return
|
||||
}
|
||||
original := cloneTracks(saved)
|
||||
saved = append(saved[:idx:idx], saved[idx+1:]...)
|
||||
saved = append(saved[:savedIdx:savedIdx], saved[savedIdx+1:]...)
|
||||
if err := saver.SavePlaylist(loaded, saved); err != nil {
|
||||
m.status.Showf(statusTTLDefault, "Remove failed: %s", err)
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user