18 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 | 8 | execute | 1 |
|
true |
|
|
Purpose: Prevent accidental duplicate track additions while giving the user full control. Output: Backend duplicate detection method, new dialog component, updated playlist-picker and playlist-view drag-drop to use the dialog.
<execution_context> @/home/caleb/.config/Claude/get-shit-done/workflows/execute-plan.md @/home/caleb/.config/Claude/get-shit-done/templates/summary.md </execution_context>
@.planning/STATE.md @frontend/src/components/playlist-picker/playlist-picker.ts @frontend/src/components/track-details/track-details.ts @frontend/src/components/phantom-resolver/phantom-resolver.ts @frontend/src/components/playlist-view/playlist-view.ts @backend/playlist/playlist.go @backend/database/sql/queries/playlists.sqlFrom backend/playlist/playlist.go:
type Track struct {
ID int64 `json:"ID"`
Position int64 `json:"Position"`
FilePath string `json:"FilePath"`
Title string `json:"Title"`
Artist string `json:"Artist"`
Album string `json:"Album"`
CoverArtPath string `json:"CoverArtPath"`
CoverArtSmall string `json:"CoverArtSmall"`
CoverArtMedium string `json:"CoverArtMedium"`
CoverArtLarge string `json:"CoverArtLarge"`
Duration string `json:"Duration"`
Phantom bool `json:"Phantom"`
}
func (s *Service) AddTracksToPlaylist(playlistID int64, filePaths []string) error
From backend/database/sql/queries/playlists.sql:
-- name: IsTrackInPlaylist :one
SELECT EXISTS(
SELECT 1 FROM playlist_tracks pt
JOIN audio_files af ON pt.audio_file_id = af.id
WHERE pt.playlist_id = ? AND af.file_path = ?
) AS in_playlist;
-- name: GetPlaylistTrackFilePaths :many
SELECT af.file_path
FROM playlist_tracks pt
JOIN audio_files af ON pt.audio_file_id = af.id
WHERE pt.playlist_id = ?
ORDER BY pt.position;
From frontend — playlist-picker fires playlist-action-complete event on success.
From frontend — wa-dialog pattern (from track-details.ts):
import '@awesome.me/webawesome/dist/components/dialog/dialog.js';
@query('wa-dialog')
private dialog!: HTMLElement & { open: boolean };
show() { this.updateComplete.then(() => { this.dialog.open = true; }); }
close() { this.dialog.open = false; }
From frontend — wa-switch component is available at:
@awesome.me/webawesome/dist/components/switch/switch.js
Important: Wails bindings only support (T, error) or error return signatures. Use a wrapper struct:
// DuplicateTrackInfo holds metadata for a track that already exists in a playlist.
type DuplicateTrackInfo struct {
FilePath string `json:"FilePath"`
Title string `json:"Title"`
Artist string `json:"Artist"`
Album string `json:"Album"`
Duration string `json:"Duration"`
}
// DuplicateCheckResult contains the outcome of checking for duplicate tracks.
type DuplicateCheckResult struct {
Duplicates []DuplicateTrackInfo `json:"Duplicates"`
Unique []string `json:"Unique"`
}
// FindDuplicateTracksInPlaylist checks which of the given file paths
// already exist in the specified playlist. Returns metadata for each
// duplicate and a list of non-duplicate file paths.
func (s *Service) FindDuplicateTracksInPlaylist(
playlistID int64,
filePaths []string,
) (DuplicateCheckResult, error)
Implementation:
- Call
s.db.Queries.GetPlaylistTracksWithMetadata(s.db.Ctx, playlistID)once. - Build
existingPaths map[string]sqlcgen.GetPlaylistTracksWithMetadataRowfrom results, keyed byrow.FilePath. - For each incoming filePath:
- If in map → append
DuplicateTrackInfowith Title, Artist, Album, LengthMilliseconds from the row. - If not in map → append to
Uniqueslice.
- If in map → append
- Return
DuplicateCheckResult{Duplicates: duplicates, Unique: unique}, nil. - If the initial query fails, return the error.
After adding the method, run wails generate module from the project root to regenerate the TypeScript bindings.
go build ./backend/playlist/... compiles without errors. Run wails generate module and confirm frontend/wailsjs/go/playlist/Service.d.ts contains FindDuplicateTracksInPlaylist.
Backend exposes FindDuplicateTracksInPlaylist(playlistID, filePaths) returning duplicate track info and unique paths. Wails TypeScript bindings regenerated.
Component API:
import { LitElement, html, css, nothing } from 'lit';
import { customElement, state, query } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/dialog/dialog.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@awesome.me/webawesome/dist/components/switch/switch.js';
import { AddTracksToPlaylist } from '@go/playlist/Service';
import type { playlist } from '@go/models';
interface DuplicateTrack {
FilePath: string;
Title: string;
Artist: string;
Album: string;
Duration: string;
}
@customElement('duplicate-tracks-dialog')
export class DuplicateTracksDialog extends LitElement {
@query('wa-dialog')
private dialog!: HTMLElement & { open: boolean };
@state() private duplicates: DuplicateTrack[] = [];
@state() private currentIndex = 0;
@state() private applyToAll = false;
private playlistId = 0;
private uniquePaths: string[] = [];
private tracksToAdd: string[] = []; // accumulated "Add" choices
/** Opens the dialog. Called by playlist-picker when duplicates are found. */
show(
playlistId: number,
duplicates: DuplicateTrack[],
uniquePaths: string[],
): void { ... }
close(): void { ... }
}
Dialog layout:
wa-dialogwith label "Duplicate Tracks Found"--width: 480px- Header text: "{N} duplicate track(s) already exist in this playlist."
- Progress indicator: "Track {current} of {total}"
- Current track card showing: Title (bold, 15px), Artist (secondary, 13px), Album (tertiary, 13px), Duration (tertiary, 12px, tabular-nums)
- A
wa-switchwith label "Apply to all remaining" — when toggled on, the next Add/Skip applies to all remaining duplicates at once. - Two action buttons at the bottom: "Skip" (secondary .btn style) and "Add" (primary .btn-primary style, accent colored).
Behavior:
show()stores playlistId, duplicates, uniquePaths. Sets currentIndex=0, applyToAll=false, tracksToAdd=[]. Opens dialog.- When "Add" is clicked:
- Push
duplicates[currentIndex].FilePathtotracksToAdd. - If
applyToAllis true: push ALL remaining duplicate file paths totracksToAdd, then finalize. - Else: advance
currentIndex. If past end, finalize.
- Push
- When "Skip" is clicked:
- Do NOT add the current track.
- If
applyToAllis true: skip all remaining (finalize immediately). - Else: advance
currentIndex. If past end, finalize.
finalize():- Combine
uniquePaths+tracksToAddinto one array. - If array is non-empty, call
await AddTracksToPlaylist(this.playlistId, combined). - Dispatch
playlist-action-completeevent (bubbles: true, composed: true). - Close dialog.
- Combine
Styling: Follow project conventions — use --yj-* CSS custom properties. Match the track-details.ts dialog styling for consistency (same wa-dialog::part(*) rules). The track card should have a subtle background (--yj-bg-elevated), rounded corners (6px), padding (16px), and the info stacked vertically.
Use formatMilliseconds from @utils/time for duration display.
Important: The wa-switch @wa-change event fires with e.target.checked as a boolean. Use:
<wa-switch
size="small"
?checked=${this.applyToAll}
@wa-change=${(e: Event) => {
this.applyToAll = (e.target as HTMLInputElement).checked;
}}
>
Apply to all remaining
</wa-switch>
- Add imports:
import {
GetAllPlaylists,
AddTracksToPlaylist,
CreatePlaylistWithTracks,
FindDuplicateTracksInPlaylist,
} from '@go/playlist/Service';
import '@components/duplicate-tracks-dialog/duplicate-tracks-dialog.js';
import type { DuplicateTracksDialog } from '@components/duplicate-tracks-dialog/duplicate-tracks-dialog.js';
- Add a query for the dialog (render it in the template):
@query('duplicate-tracks-dialog')
private duplicateDialog!: DuplicateTracksDialog;
- Modify
handleSelectPlaylistto check for duplicates BEFORE adding:
private handleSelectPlaylist = async (playlistId: number) => {
if (this.loading || this.filePaths.length === 0) return;
this.loading = true;
try {
const result = await FindDuplicateTracksInPlaylist(playlistId, this.filePaths);
const duplicates = result.Duplicates ?? [];
const unique = result.Unique ?? [];
if (duplicates.length > 0) {
// Show dialog — it will handle adding tracks and dispatching completion
this.loading = false;
await this.updateComplete;
this.duplicateDialog.show(playlistId, duplicates, unique);
return;
}
// No duplicates — add all directly
await AddTracksToPlaylist(playlistId, this.filePaths);
this.dispatchComplete();
} catch (err) {
console.error('Failed to add tracks to playlist:', err);
} finally {
this.loading = false;
}
};
- Add the dialog element to the render template, just before the closing of
renderPlaylistList()andrenderCreateForm()— or better, add it to the mainrender()method so it's always in the DOM:
override render() {
return html`
${this.mode === 'create' ? this.renderCreateForm() : this.renderPlaylistList()}
<duplicate-tracks-dialog
@playlist-action-complete=${this.dispatchComplete}
></duplicate-tracks-dialog>
`;
}
Note: The dispatchComplete call from the dialog will bubble up through the playlist-picker, which is exactly what consumers listen for. The dialog's playlist-action-complete event is caught here and re-dispatched by the picker's own dispatchComplete.
playlist-view.ts changes:
- Add imports at top (near existing imports):
import { FindDuplicateTracksInPlaylist } from '@go/playlist/Service';
import '@components/duplicate-tracks-dialog/duplicate-tracks-dialog.js';
import type { DuplicateTracksDialog } from '@components/duplicate-tracks-dialog/duplicate-tracks-dialog.js';
- Add a query for the dialog:
@query('duplicate-tracks-dialog')
private duplicateDialog!: DuplicateTracksDialog;
- Find the drag-drop handler
handlePlaylistDrop(around line ~1823) that callsawait AddTracksToPlaylist(entry.summary.ID, payload.filePaths)and wrap it with duplicate detection:
// Replace the direct AddTracksToPlaylist call:
const result = await FindDuplicateTracksInPlaylist(
entry.summary.ID,
payload.filePaths,
);
const duplicates = result.Duplicates ?? [];
const unique = result.Unique ?? [];
if (duplicates.length > 0) {
await this.updateComplete;
this.duplicateDialog.show(entry.summary.ID, duplicates, unique);
return;
}
await AddTracksToPlaylist(entry.summary.ID, payload.filePaths);
await this.refreshPlaylists();
- Add
<duplicate-tracks-dialog>to the playlist-view's render output. Find the location where<track-details>and<phantom-resolver>are rendered (likely near the end of the main render method) and add alongside them:
<duplicate-tracks-dialog
@playlist-action-complete=${() => this.refreshPlaylists()}
></duplicate-tracks-dialog>
Return type handling: The Go method returns ([]DuplicateTrackInfo, []string, error). Wails will generate a TypeScript binding that returns an object. After running wails generate module in Task 1, check the generated types in frontend/wailsjs/go/playlist/Service.d.ts and frontend/wailsjs/go/models.ts to confirm the return shape. Go functions with multiple return values are mapped by Wails — typically a struct wrapper is needed.
Important adjustment: Go functions exposed to Wails can only return (T, error) or error. Multiple return values won't work. So in Task 1, the method must return a struct:
type DuplicateCheckResult struct {
Duplicates []DuplicateTrackInfo `json:"Duplicates"`
Unique []string `json:"Unique"`
}
func (s *Service) FindDuplicateTracksInPlaylist(
playlistID int64,
filePaths []string,
) (DuplicateCheckResult, error)
This way Wails generates FindDuplicateTracksInPlaylist(playlistID: number, filePaths: string[]): Promise<playlist.DuplicateCheckResult> and the frontend accesses result.Duplicates and result.Unique.
npm run build compiles. Test manually: drag tracks that are already in a playlist onto that playlist in the playlist-view sidebar — the duplicate dialog should appear. Using the context menu "Add to playlist" picker with tracks that already exist should also trigger the dialog. Adding tracks with no duplicates should work without any dialog.
Playlist-picker and playlist-view drag-drop both check for duplicates before adding. When duplicates found, the dialog appears for one-by-one resolution. When no duplicates, tracks are added directly as before.
<success_criteria>
- Duplicate detection works for both playlist-picker (context menu) and playlist-view (drag-drop) flows
- Dialog shows track details (title, artist, album, duration) for each duplicate
- Add/Skip buttons advance through duplicates one at a time
- "Apply to all remaining" toggle batch-applies the current choice
- Non-duplicate tracks are always added regardless of dialog choices
- No dialog appears when there are zero duplicates </success_criteria>