Files
yellowjacket/.planning/quick/19-fix-phantom-playlist-tracks/19-PLAN.md
T

11 KiB
Raw Blame History

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
backend/playlist/playlist.go
backend/playlist/m3u.go
backend/playlist/m3u_test.go
true
truths artifacts key_links
Playlist tracks resolve correctly when multiple libraries exist
Playlist tracks resolve correctly after FullRescan with multiple libraries
M3U8 relative paths are resolved against all library roots, not just the first
M3U8 relative paths are saved relative to the correct library root for each track
path provides
backend/playlist/playlist.go getAllLibraryRoots() replacing getLibraryRoot(), multi-root resolution at all call sites
path provides
backend/playlist/m3u.go toAbsolutePathMultiRoot() and updated helper functions
path provides
backend/playlist/m3u_test.go Tests for multi-root path resolution
from to via pattern
backend/playlist/playlist.go mergeTracksForPlaylist() backend/playlist/m3u.go toAbsolutePathMultiRoot() function call for each M3U8 entry toAbsolutePathMultiRoot
Fix phantom playlist tracks caused by single-library-root path resolution in a multi-library environment.

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.go

From 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
Task 1: Add multi-root path resolution to m3u.go and update playlist.go call sites backend/playlist/m3u.go backend/playlist/playlist.go **Part A — m3u.go: Add multi-root resolution functions**
  1. Add toAbsolutePathMultiRoot(relativePath string, libraryRoots []string) string:

    • If filepath.IsAbs(relativePath), return as-is (same as current)
    • For each root in libraryRoots, compute filepath.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{}) string that:
      • If filepath.IsAbs(relativePath) and path is in knownPaths, return it
      • For each root, compute absPath := filepath.Join(root, relativePath) and check knownPaths[absPath]; if found, return absPath
      • If no root matches, fall back to filepath.Join(libraryRoots[0], relativePath) if roots non-empty, else return relativePath (preserves current behavior for phantoms)
    • This keeps resolution in one place and avoids O(n) loops at each call site.
  2. Update removeM3UEntries signature: removeM3UEntries(entries []m3uEntry, targetAbsPaths map[string]struct{}, libraryRoots []string) []m3uEntry

    • Inside, for each entry, try toAbsolutePath against each root, check if any resolves into targetAbsPaths
  3. Update replaceM3UEntryPaths signature: replaceM3UEntryPaths(entries []m3uEntry, replacements map[string]string, libraryRoots []string) []m3uEntry

    • Inside, for each entry, try each root, check if any resolves into replacements
  4. Update findM3UEntry signature: findM3UEntry(entries []m3uEntry, targetAbsPath string, libraryRoots []string) (m3uEntry, int)

    • Inside, for each entry, try each root, compare against target
  5. 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)

Part B — playlist.go: Replace getLibraryRoot with getAllLibraryRoots

  1. Replace getLibraryRoot() string with getAllLibraryRoots() []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
    }
    
  2. Update mergeTracksForPlaylist (line ~370):

    • Change libraryRoot := s.getLibraryRoot() to libraryRoots := s.getAllLibraryRoots()
    • Build a knownPaths set from dbTracks keys: 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 of toAbsolutePath(entry.RelativePath, libraryRoot)
  3. Update importPlaylist (line ~844):

    • Change to libraryRoots := s.getAllLibraryRoots()
    • For each entry, try resolving against each root: absPath := toAbsolutePath(entry.RelativePath, root) then GetAudioFileByPath(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 GetAudioFileByPath succeeds.
  4. Update RestoreAllPlaylists / restoreSinglePlaylist (line ~984, ~1018):

    • Same pattern: pass libraryRoots instead of libraryRoot
    • restoreSinglePlaylist already receives libraryRoot string — change to libraryRoots []string
    • Inside, for each M3U8 entry, try each root
  5. Update buildM3UEntries (line ~1304):

    • Use toRelativePathMultiRoot(row.FilePath, libraryRoots) — this finds the correct root for each track's absolute path
  6. Update saveImportedPlaylistFile (line ~1245):

    • Change to multi-root, use toRelativePathMultiRoot
  7. Update FindPhantomMatches (line ~1448):

    • Pass libraryRoots to findM3UEntry and entry resolution
  8. Update GetPhantomCandidates (line ~1536):

    • Same pattern
  9. Update ResolvePhantomTracks (line ~1616):

    • Same pattern
  10. 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 with getAllLibraryRoots() returning all library paths
    • mergeTracksForPlaylist resolves M3U8 entries against all roots
    • restoreSinglePlaylist resolves M3U8 entries against all roots
    • buildM3UEntries saves relative paths using correct root per track
    • All m3u.go helper functions accept []string roots
    • All 9 call sites updated
Task 2: Add multi-root path resolution tests backend/playlist/m3u_test.go Add tests to m3u_test.go for the new multi-root functions:
  1. TestToAbsolutePathMultiRoot (or TestResolveM3UPath):

    • 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)
  2. 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
  3. Update existing TestRemoveM3UEntries to use []string{"/music"} instead of "/music"

  4. Update existing TestRemoveM3UEntriesAll similarly

  5. Update existing TestReplaceM3UEntryPaths similarly

  6. Update existing TestFindM3UEntry similarly

  7. Add a multi-root variant of TestRemoveM3UEntries:

    • Entries from different roots, targets from mixed roots, correct entries removed
  8. 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

1. `go build ./backend/...` — compiles cleanly 2. `go vet ./backend/playlist/...` — no issues 3. `go test ./backend/playlist/...` — all tests pass (existing + new) 4. `go test ./backend/...` — full backend test suite passes (no regressions)

<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>
After completion, create `.planning/quick/19-fix-phantom-playlist-tracks/19-SUMMARY.md`