docs(quick-19): fix phantom playlist tracks

This commit is contained in:
2026-03-16 01:10:40 -04:00
parent e358b57ddf
commit a586e13a3a
2 changed files with 257 additions and 1 deletions
+1 -1
View File
@@ -26,7 +26,7 @@ Phase: 12 — Library CRUD & Data Integrity (completed), 13 next
Plan: 2 of 2 in Phase 12 (complete)
Status: Plan 12-02 complete — frontend library management UI verified
Progress: ████████████████████ Phase 12 complete (2/2 plans), Phase 14 complete (4/4 plans)
Last activity: 2026-03-15 — Completed 12-02 frontend library management UI
Last activity: 2026-03-16 — Completed quick task 19: fix phantom playlist tracks
### Phase Overview
@@ -0,0 +1,256 @@
---
phase: quick-19
plan: 19
type: execute
wave: 1
depends_on: []
files_modified:
- backend/playlist/playlist.go
- backend/playlist/m3u.go
- backend/playlist/m3u_test.go
autonomous: true
requirements: []
must_haves:
truths:
- "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"
artifacts:
- path: "backend/playlist/playlist.go"
provides: "getAllLibraryRoots() replacing getLibraryRoot(), multi-root resolution at all call sites"
- path: "backend/playlist/m3u.go"
provides: "toAbsolutePathMultiRoot() and updated helper functions"
- path: "backend/playlist/m3u_test.go"
provides: "Tests for multi-root path resolution"
key_links:
- from: "backend/playlist/playlist.go mergeTracksForPlaylist()"
to: "backend/playlist/m3u.go toAbsolutePathMultiRoot()"
via: "function call for each M3U8 entry"
pattern: "toAbsolutePathMultiRoot"
---
<objective>
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.
</objective>
<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>
<context>
@.planning/STATE.md
@backend/playlist/playlist.go
@backend/playlist/m3u.go
@backend/playlist/m3u_test.go
</context>
<interfaces>
<!-- Key types and contracts the executor needs. -->
From backend/playlist/m3u.go:
```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:
```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:
```go
func (q *Queries) GetAllLibraries(ctx context.Context) ([]Library, error)
// Library has: ID int64, Name string, Path string
```
</interfaces>
<tasks>
<task type="auto">
<name>Task 1: Add multi-root path resolution to m3u.go and update playlist.go call sites</name>
<files>
backend/playlist/m3u.go
backend/playlist/playlist.go
</files>
<action>
**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`:
```go
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
</action>
<verify>
go build ./backend/... compiles without errors
go vet ./backend/playlist/... passes
</verify>
<done>
- `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
</done>
</task>
<task type="auto">
<name>Task 2: Add multi-root path resolution tests</name>
<files>backend/playlist/m3u_test.go</files>
<action>
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.
</action>
<verify>
go test ./backend/playlist/... -run "TestResolveM3UPath|TestToRelativePathMultiRoot|TestRemoveM3UEntries|TestReplaceM3UEntryPaths|TestFindM3UEntry" -v passes
</verify>
<done>
- 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
</done>
</task>
</tasks>
<verification>
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)
</verification>
<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>
<output>
After completion, create `.planning/quick/19-fix-phantom-playlist-tracks/19-SUMMARY.md`
</output>