11 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | |||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| quick-19 | 19 | execute | 1 |
|
true |
|
Purpose: The playlist M3U8 merge logic uses getLibraryRoot() which returns only the first library's path. When tracks belong to other libraries, their relative M3U8 paths resolve against the wrong root, causing them to appear as phantoms. After FullRescan, RestoreAllPlaylists also only uses one root, so tracks from non-first libraries fail to re-link.
Output: Multi-root path resolution across all playlist path operations — tracks from any library resolve correctly.
<execution_context> @/home/caleb/.config/opencode/get-shit-done/workflows/execute-plan.md @/home/caleb/.config/opencode/get-shit-done/templates/summary.md </execution_context>
@.planning/STATE.md @backend/playlist/playlist.go @backend/playlist/m3u.go @backend/playlist/m3u_test.goFrom backend/playlist/m3u.go:
func toAbsolutePath(relativePath, libraryRoot string) string
func toRelativePath(absolutePath, libraryRoot string) string
func removeM3UEntries(entries []m3uEntry, targetAbsPaths map[string]struct{}, libraryRoot string) []m3uEntry
func replaceM3UEntryPaths(entries []m3uEntry, replacements map[string]string, libraryRoot string) []m3uEntry
func findM3UEntry(entries []m3uEntry, targetAbsPath string, libraryRoot string) (m3uEntry, int)
From backend/playlist/playlist.go:
func (s *Service) getLibraryRoot() string // returns single root — THE BUG
// Called at lines: 370, 844, 984, 1304, 1448, 1536, 1616, 1743
From backend/database/sql/sqlcgen/libraries.sql.go:
func (q *Queries) GetAllLibraries(ctx context.Context) ([]Library, error)
// Library has: ID int64, Name string, Path string
-
Add
toAbsolutePathMultiRoot(relativePath string, libraryRoots []string) string:- If
filepath.IsAbs(relativePath), return as-is (same as current) - For each root in
libraryRoots, computefilepath.Join(root, relativePath)— return the first result (all roots will produce valid-looking paths; the caller uses the result as a map key, so the first root that matches wins) - Actually, this function should try each root and check if the resulting path exists as a key in a provided map. BUT that couples m3u.go to the caller's map. Instead, return ALL possible absolute paths and let the caller check.
- Better approach: Keep it simple. Add
resolveM3UPath(relativePath string, libraryRoots []string, knownPaths map[string]struct{}) stringthat:- If
filepath.IsAbs(relativePath)and path is inknownPaths, return it - For each root, compute
absPath := filepath.Join(root, relativePath)and checkknownPaths[absPath]; if found, returnabsPath - If no root matches, fall back to
filepath.Join(libraryRoots[0], relativePath)if roots non-empty, else returnrelativePath(preserves current behavior for phantoms)
- If
- This keeps resolution in one place and avoids O(n) loops at each call site.
- If
-
Update
removeM3UEntriessignature:removeM3UEntries(entries []m3uEntry, targetAbsPaths map[string]struct{}, libraryRoots []string) []m3uEntry- Inside, for each entry, try
toAbsolutePathagainst each root, check if any resolves intotargetAbsPaths
- Inside, for each entry, try
-
Update
replaceM3UEntryPathssignature:replaceM3UEntryPaths(entries []m3uEntry, replacements map[string]string, libraryRoots []string) []m3uEntry- Inside, for each entry, try each root, check if any resolves into
replacements
- Inside, for each entry, try each root, check if any resolves into
-
Update
findM3UEntrysignature:findM3UEntry(entries []m3uEntry, targetAbsPath string, libraryRoots []string) (m3uEntry, int)- Inside, for each entry, try each root, compare against target
-
Add
toRelativePathMultiRoot(absolutePath string, libraryRoots []string) string:- Try
filepath.Rel(root, absolutePath)for each root - Return the first result that doesn't start with ".." (i.e., the path is under that root)
- If no root matches, return
absolutePath(keeps absolute, same as current fallback)
- Try
Part B — playlist.go: Replace getLibraryRoot with getAllLibraryRoots
-
Replace
getLibraryRoot() stringwithgetAllLibraryRoots() []string:func (s *Service) getAllLibraryRoots() []string { // Legacy config fallback. if s.libraryDir != nil { if dir := s.libraryDir.GetLibraryDirectory(); dir != "" { return []string{dir} } } libs, err := s.db.Queries.GetAllLibraries(s.db.Ctx) if err != nil || len(libs) == 0 { return nil } roots := make([]string, len(libs)) for i, lib := range libs { roots[i] = lib.Path } return roots } -
Update
mergeTracksForPlaylist(line ~370):- Change
libraryRoot := s.getLibraryRoot()tolibraryRoots := s.getAllLibraryRoots() - Build a
knownPathsset fromdbTrackskeys:knownPaths := make(map[string]struct{}, len(dbTracks)); for k := range dbTracks { knownPaths[k] = struct{}{} } - Replace the loop body to use
resolveM3UPath(entry.RelativePath, libraryRoots, knownPaths)instead oftoAbsolutePath(entry.RelativePath, libraryRoot)
- Change
-
Update
importPlaylist(line ~844):- Change to
libraryRoots := s.getAllLibraryRoots() - For each entry, try resolving against each root:
absPath := toAbsolutePath(entry.RelativePath, root)thenGetAudioFileByPath(absPath) - But that's O(entries × roots) DB queries. Better: just try each root's absolute path, use first that works. Wrap in a helper if cleaner.
- Actually, for import, the current approach makes one DB query per entry. With multi-root, wrap in a loop: try each root until
GetAudioFileByPathsucceeds.
- Change to
-
Update
RestoreAllPlaylists/restoreSinglePlaylist(line ~984, ~1018):- Same pattern: pass
libraryRootsinstead oflibraryRoot restoreSinglePlaylistalready receiveslibraryRoot string— change tolibraryRoots []string- Inside, for each M3U8 entry, try each root
- Same pattern: pass
-
Update
buildM3UEntries(line ~1304):- Use
toRelativePathMultiRoot(row.FilePath, libraryRoots)— this finds the correct root for each track's absolute path
- Use
-
Update
saveImportedPlaylistFile(line ~1245):- Change to multi-root, use
toRelativePathMultiRoot
- Change to multi-root, use
-
Update
FindPhantomMatches(line ~1448):- Pass
libraryRootstofindM3UEntryand entry resolution
- Pass
-
Update
GetPhantomCandidates(line ~1536):- Same pattern
-
Update
ResolvePhantomTracks(line ~1616):- Same pattern
-
Update
RemovePhantomTracks(line ~1743):- Same pattern
Delete getLibraryRoot after all call sites are migrated. Keep toAbsolutePath and toRelativePath — they're still useful as single-root primitives called by the multi-root wrappers.
IMPORTANT CODEBASE PATTERNS:
- Mutex-protected setter pattern (lock → write → release → callbacks)
- SAFETY comment convention for hand-crafted SQL
- Do NOT change any SQL queries — this is purely Go-side path resolution
go build ./backend/... compiles without errors
go vet ./backend/playlist/... passes
getLibraryRoot()replaced withgetAllLibraryRoots()returning all library pathsmergeTracksForPlaylistresolves M3U8 entries against all rootsrestoreSinglePlaylistresolves M3U8 entries against all rootsbuildM3UEntriessaves relative paths using correct root per track- All m3u.go helper functions accept
[]stringroots - All 9 call sites updated
-
TestToAbsolutePathMultiRoot(orTestResolveM3UPath):- Relative path resolves against first matching root
- Absolute path returned as-is
- Empty roots returns relative path unchanged
- Path under second root resolves correctly (not just first)
-
TestToRelativePathMultiRoot:- Path under first root returns relative to first root
- Path under second root returns relative to second root
- Path under no root returns absolute
- Empty roots returns absolute
-
Update existing
TestRemoveM3UEntriesto use[]string{"/music"}instead of"/music" -
Update existing
TestRemoveM3UEntriesAllsimilarly -
Update existing
TestReplaceM3UEntryPathssimilarly -
Update existing
TestFindM3UEntrysimilarly -
Add a multi-root variant of
TestRemoveM3UEntries:- Entries from different roots, targets from mixed roots, correct entries removed
-
Add a multi-root variant of
TestFindM3UEntry:- Entry relative to second root found correctly
Follow existing test patterns: table-driven, t.Parallel(), descriptive names.
go test ./backend/playlist/... -run "TestResolveM3UPath|TestToRelativePathMultiRoot|TestRemoveM3UEntries|TestReplaceM3UEntryPaths|TestFindM3UEntry" -v passes
- Multi-root resolution tested with multiple library roots
- Edge cases covered (empty roots, absolute paths, no match)
- Existing tests updated for new signatures
- All tests pass
<success_criteria>
- Playlist M3U8 path resolution tries all library roots instead of just the first
- M3U8 save uses the correct library root for each track's absolute path
- All existing playlist tests continue to pass
- New tests verify multi-root resolution behavior
- No changes to SQL queries or schema </success_criteria>