From e192d4625eff8e93021525e1fa0a7cf131f70ec6 Mon Sep 17 00:00:00 2001 From: Caleb Allen Date: Tue, 24 Feb 2026 21:23:25 -0500 Subject: [PATCH] playlist phantom track matching added, updated search queries for efficiency --- PLAN-fts-search-and-genre-query.md | 352 +++++ backend/database/database.go | 109 ++ backend/database/search.go | 410 ++++++ backend/database/sql/queries/audio_files.sql | 23 +- backend/database/sql/queries/genres.sql | 47 + backend/database/sql/schemas/audio_files.sql | 1 + backend/database/sql/schemas/search_index.sql | 8 + .../database/sql/sqlcgen/audio_files.sql.go | 85 +- backend/database/sql/sqlcgen/genres.sql.go | 137 ++ backend/database/sql/sqlcgen/models.go | 8 + backend/library/library.go | 97 +- backend/library/query.go | 205 ++- backend/library/rescan.go | 9 + backend/playlist/m3u.go | 90 +- backend/playlist/m3u_test.go | 282 +++- backend/playlist/match.go | 379 +++++ backend/playlist/match_test.go | 411 ++++++ backend/playlist/playlist.go | 575 +++++++- .../components/genre-details/genre-details.ts | 38 +- .../src/components/genres-view/genres-view.ts | 166 +-- .../phantom-resolver/phantom-resolver.ts | 1302 +++++++++++++++++ .../components/playlist-view/playlist-view.ts | 645 ++++++-- .../components/track-details/track-details.ts | 9 +- frontend/wailsjs/go/library/Library.d.ts | 6 + frontend/wailsjs/go/library/Library.js | 12 + frontend/wailsjs/go/models.ts | 102 ++ frontend/wailsjs/go/playlist/Service.d.ts | 10 + frontend/wailsjs/go/playlist/Service.js | 20 + frontend/wailsjs/runtime/package.json | 0 frontend/wailsjs/runtime/runtime.d.ts | 0 frontend/wailsjs/runtime/runtime.js | 0 31 files changed, 5222 insertions(+), 316 deletions(-) create mode 100644 PLAN-fts-search-and-genre-query.md create mode 100644 backend/database/search.go create mode 100644 backend/database/sql/schemas/search_index.sql create mode 100644 backend/playlist/match.go create mode 100644 backend/playlist/match_test.go create mode 100644 frontend/src/components/phantom-resolver/phantom-resolver.ts mode change 100644 => 100755 frontend/wailsjs/runtime/package.json mode change 100644 => 100755 frontend/wailsjs/runtime/runtime.d.ts mode change 100644 => 100755 frontend/wailsjs/runtime/runtime.js diff --git a/PLAN-fts-search-and-genre-query.md b/PLAN-fts-search-and-genre-query.md new file mode 100644 index 0000000..cf9b322 --- /dev/null +++ b/PLAN-fts-search-and-genre-query.md @@ -0,0 +1,352 @@ +# Plan: Track List FTS Search (#1) & Genre Details Query (#4) + +## Feature #1: Track List FTS Search + +### Goal + +When the user types in the track list search bar, delegate to the backend +FTS5 index instead of filtering all tracks in-memory in JavaScript. +Backend-only search with debounce. FTS5 index stays as-is (title, artist, +album, file_path — no expansion). + +### Current flow + +1. All tracks fetched once via `Library.GetAllTracks()` → cached in + `libraryStore` +2. On each keystroke, `computeFilteredTracks()` in `track-list.ts` runs + `toLowerCase().includes(term)` across every track's active columns +3. Virtual scrolling renders only visible rows + +### Proposed flow + +1. All tracks still fetched and cached (needed for empty-search display, + sorting, column rendering) +2. When search term is non-empty, call new backend method + `Library.SearchTracks(query)` which uses FTS5 internally +3. Backend returns `[]library.Track` (same 16-field type as `GetAllTracks`) +4. Frontend uses these results directly instead of client-side filtering +5. Frontend debounces the backend call (~200-250ms) to avoid excessive + round-trips on fast typing + +### Backend changes + +#### 1. `backend/database/search.go` — New method `SearchFTSTracks` + +Add `SearchFTSTracks(query string, limit int)` method on `*DB`. + +- Uses `buildFTSQuery(query)` to tokenise the user input +- Runs FTS5 MATCH against `search_index` +- JOINs to all the same tables as `GetAllTracksWithFullMetadata`: + `audio_files`, `recordings`, `artist_credit`, `release_group_recordings`, + `release_groups`, `file_types` +- Includes the `GROUP_CONCAT` subquery for genres +- Returns all 16 columns needed for `library.Track` +- Returns a new `SearchTrackRow` struct (or reuse generated types if + practical) + +Query shape: + +```sql +SELECT + af.file_path, + af.length_milliseconds, + COALESCE(r.name, '') AS title, + COALESCE(ac.text, '') AS artist_name, + r.track_number, + r.disc_number, + COALESCE(rg.name, '') AS album, + CAST(COALESCE( + (SELECT GROUP_CONCAT(g.name, '||') + FROM recording_genres rg_sub + JOIN genres g ON rg_sub.genre_id = g.id + WHERE rg_sub.recording_id = r.id), + '' + ) AS TEXT) AS genre, + COALESCE(r.year, 0) AS year, + COALESCE(r.composer, '') AS composer, + COALESCE(ft.extension, '') AS file_type, + af.sample_rate, + af.bit_depth, + af.channels, + af.bitrate, + af.file_size +FROM search_index si +JOIN audio_files af ON af.id = si.rowid +JOIN recordings r ON af.recording_id = r.id +JOIN artist_credit ac ON r.artist_credit_id = ac.id +LEFT JOIN release_group_recordings rgr ON r.id = rgr.recording_id +LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id +LEFT JOIN file_types ft ON af.file_type_id = ft.id +WHERE search_index MATCH ? +ORDER BY rank +LIMIT ? +``` + +Define a `SearchTrackRow` struct with all 16 fields (using `sql.NullInt64` +for track_number, disc_number, year; `sql.NullString` for composer). + +#### 2. `backend/library/query.go` — New Wails-bound method `SearchTracks` + +```go +func (l *Library) SearchTracks(query string) ([]Track, error) +``` + +- Calls `l.db.SearchFTSTracks(query, 200)` (cap at 200 results) +- Maps each `SearchTrackRow` to `library.Track` using the same logic as + `GetAllTracks` (splitGenres, NullInt64 unwrap, etc.) +- Reuse or extract common row-mapping into a shared helper to avoid + duplication with `GetAllTracks` + +### Frontend changes + +#### 3. `frontend/src/store/library-store.ts` — Add search method + state + +Add to `LibraryStore`: + +- `async searchTracks(query: string): Promise` — calls + the Wails-bound `Library.SearchTracks(query)` and returns results +- Clear any cached search results on `invalidate()` (library scan) + +#### 4. `frontend/src/components/track-list/track-list.ts` — Switch to backend search + +Changes to the search flow: + +- Remove `computeFilteredTracks()` (the in-memory filter) +- Add `@state() private searchResults: library.Track[] | null = null` +- Add `@state() private searchLoading = false` +- Add a debounced method `debouncedSearch(term: string)` (~200ms) that: + - If term is empty → sets `searchResults = null` (show all tracks) + - Otherwise → calls `libraryStore.searchTracks(term)`, stores results in + `searchResults` +- In `recomputeTrackCaches()` (or `willUpdate`): if `searchResults` is + non-null, use it as the filtered track set; otherwise use `this.tracks` +- Trigger `debouncedSearch` from the `SearchController` when the term + changes +- The sort step (`computeSortedTracks`) still runs on the filtered set + +#### 5. Wails bindings — Auto-regenerated + +After adding the Go method, run `wails generate` (or `make dev` / build) +to regenerate `frontend/wailsjs/go/library/Library.js` and `.d.ts`. + +--- + +## Feature #4: Genre Details Query + +### Goal + +Replace the fetch-all-then-filter pattern in `genre-details.ts` with a +dedicated SQL query. Also add a `GetAllGenresWithCounts` query to eliminate +the other fetch-all-tracks dependency in `genres-view.ts`. + +### Current flow (genre details) + +1. `genre-details.ts` calls `libraryCtrl.getTracks()` → fetches ALL tracks +2. Filters in JS: `tracks.filter(t => t.Genre.includes(genreName))` + +### Proposed flow (genre details) + +1. `genre-details.ts` calls new `Library.GetTracksByGenre(genreName)` +2. Backend runs a JOIN query filtered by genre name +3. Returns `[]library.Track` — same 16-field type + +### Current flow (genre list) + +1. `genres-view.ts` calls `libraryCtrl.getTracks()` → fetches ALL tracks +2. `extractGenres()` iterates every track, counts genre occurrences, + returns sorted `Genre[]` + +### Proposed flow (genre list) + +1. `genres-view.ts` calls new `Library.GetAllGenresWithCounts()` +2. Backend runs a simple GROUP BY query +3. Returns `[]GenreWithCount` (name + track count) + +### Backend changes + +#### 6. `backend/database/sql/queries/genres.sql` — Two new sqlc queries + +**Query 1: `GetTracksByGenre`** + +```sql +-- name: GetTracksByGenre :many +SELECT + af.file_path, + af.length_milliseconds, + COALESCE(r.name, '') AS title, + COALESCE(ac.text, '') AS artist_name, + r.track_number, + r.disc_number, + COALESCE(rg.name, '') AS album, + CAST(COALESCE( + (SELECT GROUP_CONCAT(g2.name, '||') + FROM recording_genres rg2 + JOIN genres g2 ON rg2.genre_id = g2.id + WHERE rg2.recording_id = r.id), + '' + ) AS TEXT) AS genre, + COALESCE(r.year, 0) AS year, + COALESCE(r.composer, '') AS composer, + COALESCE(ft.extension, '') AS file_type, + af.sample_rate, + af.bit_depth, + af.channels, + af.bitrate, + af.file_size +FROM genres g +JOIN recording_genres rg ON g.id = rg.genre_id +JOIN recordings r ON rg.recording_id = r.id +JOIN audio_files af ON af.recording_id = r.id +JOIN artist_credit ac ON r.artist_credit_id = ac.id +LEFT JOIN release_group_recordings rgr ON r.id = rgr.recording_id +LEFT JOIN release_groups rlg ON rgr.release_group_id = rlg.id +LEFT JOIN file_types ft ON af.file_type_id = ft.id +WHERE g.name = ? +ORDER BY r.name; +``` + +Uses `idx_recording_genres_genre_id` for the initial genre lookup. + +**Query 2: `GetAllGenresWithCounts`** + +```sql +-- name: GetAllGenresWithCounts :many +SELECT g.name, COUNT(rg.recording_id) AS track_count +FROM genres g +JOIN recording_genres rg ON g.id = rg.genre_id +GROUP BY g.id, g.name +ORDER BY g.name; +``` + +#### 7. `backend/library/query.go` — Two new Wails-bound methods + +**Method 1: `GetTracksByGenre`** + +```go +func (l *Library) GetTracksByGenre(genreName string) ([]Track, error) +``` + +- Calls the sqlc-generated `l.db.Queries.GetTracksByGenre(ctx, genreName)` +- Maps rows to `[]Track` using the same row-mapping helper as + `GetAllTracks` and `SearchTracks` + +**Method 2: `GetAllGenresWithCounts`** + +```go +type GenreWithCount struct { + Name string `json:"Name"` + TrackCount int64 `json:"TrackCount"` +} + +func (l *Library) GetAllGenresWithCounts() ([]GenreWithCount, error) +``` + +- Calls the sqlc-generated + `l.db.Queries.GetAllGenresWithCounts(ctx)` +- Maps rows to `[]GenreWithCount` + +#### 8. Run `make generate` to regenerate sqlc output + +After adding the queries to `genres.sql`, run `make generate` to produce +the Go types and query methods in `backend/database/sql/sqlcgen/`. + +### Frontend changes + +#### 9. `frontend/src/components/genre-details/genre-details.ts` — Use new endpoint + +Replace `loadTracks()`: + +```typescript +private async loadTracks() { + if (!this.genreName) return; + try { + this.tracks = await GetTracksByGenre(this.genreName); + } catch (error) { + console.error('Error loading genre tracks:', error); + this.tracks = []; + } finally { + this.loading = false; + } +} +``` + +- Import `GetTracksByGenre` from `@go/library/Library` +- Remove `libraryCtrl.getTracks()` call and in-memory filter +- Remove the `lastTracksRef` cache-invalidation pattern (no longer + needed — each call fetches fresh data for the specific genre) +- Still listen for `LibraryScanComplete` to re-trigger `loadTracks()` + if the genre details view is open during a rescan + +#### 10. `frontend/src/components/genres-view/genres-view.ts` — Use new endpoint + +Replace `loadGenres()`: + +- Call `Library.GetAllGenresWithCounts()` instead of fetching all tracks +- Map results directly to the local `Genre[]` array (name + trackCount) +- Remove `extractGenres()` method +- Remove `this.allTracks` state (no longer needed for genre extraction) +- Note: `allTracks` may still be needed for other purposes in the + component — check if it's used elsewhere (e.g. for passing to + genre-details). If genre-details fetches its own tracks, this + dependency chain can be fully removed. + +#### 11. Wails bindings — Auto-regenerated + +Run `wails generate` to produce the new TypeScript bindings for +`GetTracksByGenre`, `GetAllGenresWithCounts`, and `SearchTracks`. + +--- + +## Shared refactoring: Row-mapping helper + +`GetAllTracks`, `SearchTracks`, and `GetTracksByGenre` all map database +rows with the same 16 columns into `library.Track`. Currently this logic +lives inline in `GetAllTracks`. Extract it into a shared helper: + +```go +func mapTrackRow( + filePath string, + lengthMs int64, + title, artistName string, + trackNumber, discNumber sql.NullInt64, + album, genre string, + year sql.NullInt64, + composer, fileType string, + sampleRate, bitDepth, channels, bitrate, fileSize int64, +) Track +``` + +This avoids tripling the row-mapping code across three methods. + +--- + +## Implementation order + +1. Backend: extract row-mapping helper in `query.go` +2. Backend: add `SearchFTSTracks` to `search.go` + `SearchTracks` to + `query.go` +3. Backend: add sqlc queries to `genres.sql` + `make generate` +4. Backend: add `GetTracksByGenre` + `GetAllGenresWithCounts` to `query.go` +5. Verify: `make lint && make test` +6. Frontend: update `genre-details.ts` to use `GetTracksByGenre` +7. Frontend: update `genres-view.ts` to use `GetAllGenresWithCounts` +8. Frontend: update `library-store.ts` with `searchTracks` method +9. Frontend: update `track-list.ts` with debounced backend search +10. Verify: `pnpm exec tsc --noEmit` +11. Full verify: `make lint && make test` + +--- + +## Files touched (summary) + +| File | Action | +|---|---| +| `backend/database/search.go` | Add `SearchFTSTracks`, `SearchTrackRow` | +| `backend/library/query.go` | Add `SearchTracks`, `GetTracksByGenre`, `GetAllGenresWithCounts`, `GenreWithCount`, extract `mapTrackRow` helper | +| `backend/database/sql/queries/genres.sql` | Add `GetTracksByGenre`, `GetAllGenresWithCounts` | +| `backend/database/sql/sqlcgen/*` | Regenerated via `make generate` | +| `frontend/src/store/library-store.ts` | Add `searchTracks` method | +| `frontend/src/components/track-list/track-list.ts` | Replace in-memory filter with debounced backend FTS search | +| `frontend/src/components/genre-details/genre-details.ts` | Replace fetch-all-then-filter with `GetTracksByGenre` | +| `frontend/src/components/genres-view/genres-view.ts` | Replace `extractGenres` with `GetAllGenresWithCounts` | +| `frontend/wailsjs/go/library/Library.js` + `.d.ts` | Auto-regenerated | diff --git a/backend/database/database.go b/backend/database/database.go index a3733e7..50bfac6 100644 --- a/backend/database/database.go +++ b/backend/database/database.go @@ -211,6 +211,115 @@ func runMigrations( } } + // Migration 2: add basename column and populate search index. + if version < 2 { + if err := migration2BasenameAndFTS( + ctx, db, logger, + ); err != nil { + return err + } + } + + return nil +} + +// migration2BasenameAndFTS adds the basename column to audio_files, +// backfills it from file_path, creates the basename index, and +// populates the FTS5 search_index table. +func migration2BasenameAndFTS( + ctx context.Context, + db *sql.DB, + logger *slog.Logger, +) error { + logger.Info( + "applying migration 2: basename column + FTS5 search index", + ) + + // Add basename column (may already exist on fresh DBs). + if _, err := db.ExecContext( + ctx, + "ALTER TABLE audio_files ADD COLUMN basename text NOT NULL DEFAULT ''", + ); err != nil && !isDuplicateColumnErr(err) { + return fmt.Errorf( + "migration 2: could not add basename column: %w", + err, + ) + } + + // Backfill basename from file_path for existing rows. + // SQLite doesn't have a basename function, so we use + // REPLACE to strip directories by finding everything + // after the last '/'. + if _, err := db.ExecContext(ctx, ` + UPDATE audio_files + SET basename = CASE + WHEN INSTR(file_path, '/') > 0 + THEN SUBSTR( + file_path, + LENGTH(file_path) + - LENGTH( + REPLACE(file_path, '/', '') + ) + + 1 + ) + ELSE file_path + END + WHERE basename = '' + `); err != nil { + return fmt.Errorf( + "migration 2: could not backfill basename: %w", + err, + ) + } + + // Create index (IF NOT EXISTS handles fresh DBs). + if _, err := db.ExecContext(ctx, ` + CREATE INDEX IF NOT EXISTS idx_audio_files_basename + ON audio_files(basename) + `); err != nil { + return fmt.Errorf( + "migration 2: could not create basename index: %w", + err, + ) + } + + // Populate FTS5 search index from existing data. + if _, err := db.ExecContext(ctx, ` + INSERT INTO search_index(rowid, file_path, title, artist, album) + SELECT + af.id, + af.file_path, + COALESCE(r.name, ''), + COALESCE(ac.text, ''), + COALESCE(rg.name, '') + FROM audio_files af + LEFT JOIN recordings r ON af.recording_id = r.id + LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id + LEFT JOIN ( + SELECT recording_id, + MIN(release_group_id) AS release_group_id + FROM release_group_recordings + GROUP BY recording_id + ) rgr ON r.id = rgr.recording_id + LEFT JOIN release_groups rg + ON rgr.release_group_id = rg.id + `); err != nil { + return fmt.Errorf( + "migration 2: could not populate search index: %w", + err, + ) + } + + if _, err := db.ExecContext( + ctx, "PRAGMA user_version = 2", + ); err != nil { + return fmt.Errorf( + "could not set user_version to 2: %w", err, + ) + } + + logger.Info("migration 2 complete") + return nil } diff --git a/backend/database/search.go b/backend/database/search.go new file mode 100644 index 0000000..ad49655 --- /dev/null +++ b/backend/database/search.go @@ -0,0 +1,410 @@ +// Package database provides SQLite database access. +package database + +import ( + "database/sql" + "fmt" + "strings" +) + +// SearchRow holds a single result from an FTS5 or basename search. +type SearchRow struct { + FilePath string + LengthMilliseconds int64 + Title string + Artist string + Album string +} + +// SearchFTS performs a full-text search across title, artist, album, +// and file_path using the FTS5 search_index. The query string is +// tokenised by FTS5's unicode61 tokeniser. +func (d *DB) SearchFTS( + query string, limit int, +) ([]SearchRow, error) { + query = strings.TrimSpace(query) + if query == "" { + return nil, nil + } + + // Escape double quotes and wrap each token in quotes so + // special characters are treated as literals. + ftsQuery := buildFTSQuery(query) + + rows, err := d.db.QueryContext(d.Ctx, ` + SELECT + af.file_path, + af.length_milliseconds, + COALESCE(r.name, ''), + COALESCE(ac.text, ''), + COALESCE(rg.name, '') + FROM search_index si + JOIN audio_files af ON af.id = si.rowid + LEFT JOIN recordings r + ON af.recording_id = r.id + LEFT JOIN artist_credit ac + ON r.artist_credit_id = ac.id + LEFT JOIN ( + SELECT recording_id, + MIN(release_group_id) AS release_group_id + FROM release_group_recordings + GROUP BY recording_id + ) rgr ON r.id = rgr.recording_id + LEFT JOIN release_groups rg + ON rgr.release_group_id = rg.id + WHERE search_index MATCH ? + ORDER BY rank + LIMIT ? + `, ftsQuery, limit) + if err != nil { + return nil, fmt.Errorf( + "FTS search failed: %w", err, + ) + } + + defer func() { _ = rows.Close() }() + + return scanSearchRows(rows) +} + +// SearchFTSByFilename searches the file_path column of the FTS5 +// index for tokens extracted from the given basename. +func (d *DB) SearchFTSByFilename( + basename string, limit int, +) ([]SearchRow, error) { + basename = strings.TrimSpace(basename) + if basename == "" { + return nil, nil + } + + // Strip extension and build an FTS query scoped to + // the file_path column. + stem := stripExtForSearch(basename) + tokens := tokeniseForFTS(stem) + + if len(tokens) == 0 { + return nil, nil + } + + ftsQuery := "file_path : " + + strings.Join(tokens, " ") + + rows, err := d.db.QueryContext(d.Ctx, ` + SELECT + af.file_path, + af.length_milliseconds, + COALESCE(r.name, ''), + COALESCE(ac.text, ''), + COALESCE(rg.name, '') + FROM search_index si + JOIN audio_files af ON af.id = si.rowid + LEFT JOIN recordings r + ON af.recording_id = r.id + LEFT JOIN artist_credit ac + ON r.artist_credit_id = ac.id + LEFT JOIN ( + SELECT recording_id, + MIN(release_group_id) AS release_group_id + FROM release_group_recordings + GROUP BY recording_id + ) rgr ON r.id = rgr.recording_id + LEFT JOIN release_groups rg + ON rgr.release_group_id = rg.id + WHERE search_index MATCH ? + ORDER BY rank + LIMIT ? + `, ftsQuery, limit) + if err != nil { + return nil, fmt.Errorf( + "FTS filename search failed: %w", err, + ) + } + + defer func() { _ = rows.Close() }() + + return scanSearchRows(rows) +} + +// InsertSearchIndex adds a row to the FTS5 search_index. +func (d *DB) InsertSearchIndex( + rowid int64, + filePath, title, artist, album string, +) error { + _, err := d.db.ExecContext(d.Ctx, ` + INSERT INTO search_index(rowid, file_path, title, artist, album) + VALUES (?, ?, ?, ?, ?) + `, rowid, filePath, title, artist, album) + + return err +} + +// DeleteSearchIndex removes a row from the FTS5 search_index. +func (d *DB) DeleteSearchIndex(rowid int64) error { + _, err := d.db.ExecContext(d.Ctx, ` + DELETE FROM search_index WHERE rowid = ? + `, rowid) + + return err +} + +// ClearSearchIndex removes all rows from the FTS5 search_index. +func (d *DB) ClearSearchIndex() error { + _, err := d.db.ExecContext(d.Ctx, ` + DELETE FROM search_index + `) + + return err +} + +// RebuildSearchIndex repopulates the FTS5 search_index from +// scratch using current audio_files + recordings data. +func (d *DB) RebuildSearchIndex() error { + if err := d.ClearSearchIndex(); err != nil { + return fmt.Errorf( + "could not clear search index: %w", err, + ) + } + + _, err := d.db.ExecContext(d.Ctx, ` + INSERT INTO search_index(rowid, file_path, title, artist, album) + SELECT + af.id, + af.file_path, + COALESCE(r.name, ''), + COALESCE(ac.text, ''), + COALESCE(rg.name, '') + FROM audio_files af + LEFT JOIN recordings r ON af.recording_id = r.id + LEFT JOIN artist_credit ac + ON r.artist_credit_id = ac.id + LEFT JOIN ( + SELECT recording_id, + MIN(release_group_id) AS release_group_id + FROM release_group_recordings + GROUP BY recording_id + ) rgr ON r.id = rgr.recording_id + LEFT JOIN release_groups rg + ON rgr.release_group_id = rg.id + `) + if err != nil { + return fmt.Errorf( + "could not rebuild search index: %w", err, + ) + } + + return nil +} + +// SearchTrackRow holds a full track result from an FTS5 search, +// matching all 16 columns returned by GetAllTracksWithFullMetadata. +type SearchTrackRow struct { + FilePath string + LengthMilliseconds int64 + Title string + ArtistName string + TrackNumber sql.NullInt64 + DiscNumber sql.NullInt64 + Album string + Genre string + Year int64 + Composer string + FileType string + SampleRate int64 + BitDepth int64 + Channels int64 + Bitrate int64 + FileSize int64 +} + +// SearchFTSTracks performs a full-text search and returns full track +// metadata for each match. Unlike SearchFTS (which returns only 5 +// columns), this includes all 16 fields needed for library.Track. +func (d *DB) SearchFTSTracks( + query string, limit int, +) ([]SearchTrackRow, error) { + query = strings.TrimSpace(query) + if query == "" { + return nil, nil + } + + ftsQuery := buildFTSQuery(query) + + rows, err := d.db.QueryContext(d.Ctx, ` + SELECT + af.file_path, + af.length_milliseconds, + COALESCE(r.name, '') AS title, + COALESCE(ac.text, '') AS artist_name, + r.track_number, + r.disc_number, + COALESCE(rg.name, '') AS album, + CAST(COALESCE( + (SELECT GROUP_CONCAT(g.name, '||') + FROM recording_genres rg_sub + JOIN genres g ON rg_sub.genre_id = g.id + WHERE rg_sub.recording_id = r.id), + '' + ) AS TEXT) AS genre, + COALESCE(r.year, 0) AS year, + COALESCE(r.composer, '') AS composer, + COALESCE(ft.extension, '') AS file_type, + af.sample_rate, + af.bit_depth, + af.channels, + af.bitrate, + af.file_size + FROM search_index si + JOIN audio_files af ON af.id = si.rowid + LEFT JOIN recordings r + ON af.recording_id = r.id + LEFT JOIN artist_credit ac + ON r.artist_credit_id = ac.id + LEFT JOIN ( + SELECT recording_id, + MIN(release_group_id) AS release_group_id + FROM release_group_recordings + GROUP BY recording_id + ) rgr ON r.id = rgr.recording_id + LEFT JOIN release_groups rg + ON rgr.release_group_id = rg.id + LEFT JOIN file_types ft + ON af.file_type_id = ft.id + WHERE search_index MATCH ? + ORDER BY rank + LIMIT ? + `, ftsQuery, limit) + if err != nil { + return nil, fmt.Errorf( + "FTS track search failed: %w", err, + ) + } + + defer func() { _ = rows.Close() }() + + var results []SearchTrackRow + + for rows.Next() { + var r SearchTrackRow + + if err := rows.Scan( + &r.FilePath, + &r.LengthMilliseconds, + &r.Title, + &r.ArtistName, + &r.TrackNumber, + &r.DiscNumber, + &r.Album, + &r.Genre, + &r.Year, + &r.Composer, + &r.FileType, + &r.SampleRate, + &r.BitDepth, + &r.Channels, + &r.Bitrate, + &r.FileSize, + ); err != nil { + return nil, fmt.Errorf( + "could not scan search track row: %w", + err, + ) + } + + results = append(results, r) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf( + "search track row iteration error: %w", + err, + ) + } + + return results, nil +} + +// scanSearchRows reads all rows from a query result into a slice. +func scanSearchRows( + rows interface { + Next() bool + Scan(dest ...any) error + Err() error + }, +) ([]SearchRow, error) { + var results []SearchRow + + for rows.Next() { + var r SearchRow + + if err := rows.Scan( + &r.FilePath, + &r.LengthMilliseconds, + &r.Title, + &r.Artist, + &r.Album, + ); err != nil { + return nil, fmt.Errorf( + "could not scan search row: %w", err, + ) + } + + results = append(results, r) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf( + "search row iteration error: %w", err, + ) + } + + return results, nil +} + +// buildFTSQuery converts a user query string into an FTS5 query. +// Each word is quoted to escape special characters and combined +// with implicit AND. +func buildFTSQuery(query string) string { + tokens := tokeniseForFTS(query) + if len(tokens) == 0 { + return query + } + + return strings.Join(tokens, " ") +} + +// tokeniseForFTS splits a string on whitespace and common +// separators, returning quoted FTS5 tokens. +func tokeniseForFTS(s string) []string { + // Split on whitespace, hyphens, underscores, dots. + fields := strings.FieldsFunc( + s, func(r rune) bool { + return r == ' ' || r == '-' || + r == '_' || r == '.' || + r == '/' || r == '\\' + }, + ) + + tokens := make([]string, 0, len(fields)) + + for _, f := range fields { + f = strings.TrimSpace(f) + if f == "" { + continue + } + + // Escape any double quotes inside the token. + f = strings.ReplaceAll(f, `"`, `""`) + tokens = append(tokens, `"`+f+`"`) + } + + return tokens +} + +// stripExtForSearch removes the file extension from a string. +func stripExtForSearch(s string) string { + if idx := strings.LastIndexByte(s, '.'); idx > 0 { + return s[:idx] + } + + return s +} diff --git a/backend/database/sql/queries/audio_files.sql b/backend/database/sql/queries/audio_files.sql index ce7f073..25e7b32 100644 --- a/backend/database/sql/queries/audio_files.sql +++ b/backend/database/sql/queries/audio_files.sql @@ -1,5 +1,5 @@ -- name: CreateAudioFile :one -INSERT INTO audio_files (file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) +INSERT INTO audio_files (file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *; -- name: GetAudioFile :one @@ -12,7 +12,7 @@ WHERE file_path = ? LIMIT 1; -- name: UpdateAudioFile :exec UPDATE audio_files -SET file_path = ?, length_milliseconds = ?, file_type_id = ?, recording_id = ?, sample_rate = ?, bit_depth = ?, channels = ?, bitrate = ?, file_size = ? +SET file_path = ?, length_milliseconds = ?, file_type_id = ?, recording_id = ?, sample_rate = ?, bit_depth = ?, channels = ?, bitrate = ?, file_size = ?, basename = ? WHERE id = ?; -- name: UpdateAudioFileRecording :exec @@ -102,6 +102,25 @@ LEFT JOIN release_group_recordings rgr ON r.id = rgr.recording_id LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id LEFT JOIN file_types ft ON af.file_type_id = ft.id; +-- name: SearchAudioFilesByBasename :many +SELECT + af.file_path, + af.length_milliseconds, + COALESCE(r.name, '') AS title, + COALESCE(ac.text, '') AS artist, + COALESCE(rg.name, '') AS album +FROM audio_files af +LEFT JOIN recordings r ON af.recording_id = r.id +LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id +LEFT JOIN ( + SELECT recording_id, MIN(release_group_id) AS release_group_id + FROM release_group_recordings + GROUP BY recording_id +) rgr ON r.id = rgr.recording_id +LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id +WHERE af.basename = ? +LIMIT ?; + -- name: DeleteAllAudioFiles :exec DELETE FROM audio_files; diff --git a/backend/database/sql/queries/genres.sql b/backend/database/sql/queries/genres.sql index b5c15ff..0b3d01e 100644 --- a/backend/database/sql/queries/genres.sql +++ b/backend/database/sql/queries/genres.sql @@ -22,3 +22,50 @@ DELETE FROM recording_genres; -- name: DeleteAllGenres :exec DELETE FROM genres; + +-- name: GetTracksByGenre :many +SELECT + af.file_path, + af.length_milliseconds, + COALESCE(r.name, '') AS title, + COALESCE(ac.text, '') AS artist_name, + r.track_number, + r.disc_number, + COALESCE(rlg.name, '') AS album, + CAST(COALESCE( + (SELECT GROUP_CONCAT(g2.name, '||') + FROM recording_genres rg2 + JOIN genres g2 ON rg2.genre_id = g2.id + WHERE rg2.recording_id = r.id), + '' + ) AS TEXT) AS genre, + COALESCE(r.year, 0) AS year, + COALESCE(r.composer, '') AS composer, + COALESCE(ft.extension, '') AS file_type, + af.sample_rate, + af.bit_depth, + af.channels, + af.bitrate, + af.file_size +FROM genres g +JOIN recording_genres rg ON g.id = rg.genre_id +JOIN recordings r ON rg.recording_id = r.id +JOIN audio_files af ON af.recording_id = r.id +JOIN artist_credit ac ON r.artist_credit_id = ac.id +LEFT JOIN ( + SELECT recording_id, + MIN(release_group_id) AS release_group_id + FROM release_group_recordings + GROUP BY recording_id +) rgr ON r.id = rgr.recording_id +LEFT JOIN release_groups rlg ON rgr.release_group_id = rlg.id +LEFT JOIN file_types ft ON af.file_type_id = ft.id +WHERE g.name = ? +ORDER BY r.name; + +-- name: GetAllGenresWithCounts :many +SELECT g.name, COUNT(rg.recording_id) AS track_count +FROM genres g +JOIN recording_genres rg ON g.id = rg.genre_id +GROUP BY g.id, g.name +ORDER BY g.name; diff --git a/backend/database/sql/schemas/audio_files.sql b/backend/database/sql/schemas/audio_files.sql index 19c205c..4f3436c 100644 --- a/backend/database/sql/schemas/audio_files.sql +++ b/backend/database/sql/schemas/audio_files.sql @@ -9,6 +9,7 @@ CREATE TABLE IF NOT EXISTS audio_files ( channels int NOT NULL DEFAULT 0, bitrate int NOT NULL DEFAULT 0, file_size int NOT NULL DEFAULT 0, + basename text NOT NULL DEFAULT '', FOREIGN KEY(file_type_id) REFERENCES file_types(id), FOREIGN KEY(recording_id) REFERENCES recordings(id) ); diff --git a/backend/database/sql/schemas/search_index.sql b/backend/database/sql/schemas/search_index.sql new file mode 100644 index 0000000..d2f4f2c --- /dev/null +++ b/backend/database/sql/schemas/search_index.sql @@ -0,0 +1,8 @@ +CREATE VIRTUAL TABLE IF NOT EXISTS search_index USING fts5( + file_path, + title, + artist, + album, + content='', + tokenize='unicode61 remove_diacritics 2' +); diff --git a/backend/database/sql/sqlcgen/audio_files.sql.go b/backend/database/sql/sqlcgen/audio_files.sql.go index bc37221..9aa2750 100644 --- a/backend/database/sql/sqlcgen/audio_files.sql.go +++ b/backend/database/sql/sqlcgen/audio_files.sql.go @@ -22,8 +22,8 @@ func (q *Queries) CountAudioFiles(ctx context.Context) (int64, error) { } const createAudioFile = `-- name: CreateAudioFile :one -INSERT INTO audio_files (file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) -RETURNING id, file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size +INSERT INTO audio_files (file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +RETURNING id, file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename ` type CreateAudioFileParams struct { @@ -36,6 +36,7 @@ type CreateAudioFileParams struct { Channels int64 Bitrate int64 FileSize int64 + Basename string } func (q *Queries) CreateAudioFile(ctx context.Context, arg CreateAudioFileParams) (AudioFile, error) { @@ -49,6 +50,7 @@ func (q *Queries) CreateAudioFile(ctx context.Context, arg CreateAudioFileParams arg.Channels, arg.Bitrate, arg.FileSize, + arg.Basename, ) var i AudioFile err := row.Scan( @@ -62,6 +64,7 @@ func (q *Queries) CreateAudioFile(ctx context.Context, arg CreateAudioFileParams &i.Channels, &i.Bitrate, &i.FileSize, + &i.Basename, ) return i, err } @@ -118,7 +121,7 @@ func (q *Queries) GetAllAudioFilePaths(ctx context.Context) ([]GetAllAudioFilePa } const getAllAudioFiles = `-- name: GetAllAudioFiles :many -SELECT id, file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size FROM audio_files +SELECT id, file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename FROM audio_files ` func (q *Queries) GetAllAudioFiles(ctx context.Context) ([]AudioFile, error) { @@ -141,6 +144,7 @@ func (q *Queries) GetAllAudioFiles(ctx context.Context) ([]AudioFile, error) { &i.Channels, &i.Bitrate, &i.FileSize, + &i.Basename, ); err != nil { return nil, err } @@ -302,7 +306,7 @@ func (q *Queries) GetAllTracksWithFullMetadata(ctx context.Context) ([]GetAllTra } const getAudioFile = `-- name: GetAudioFile :one -SELECT id, file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size FROM audio_files +SELECT id, file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename FROM audio_files WHERE id = ? LIMIT 1 ` @@ -320,12 +324,13 @@ func (q *Queries) GetAudioFile(ctx context.Context, id int64) (AudioFile, error) &i.Channels, &i.Bitrate, &i.FileSize, + &i.Basename, ) return i, err } const getAudioFileByPath = `-- name: GetAudioFileByPath :one -SELECT id, file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size FROM audio_files +SELECT id, file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename FROM audio_files WHERE file_path = ? LIMIT 1 ` @@ -343,6 +348,7 @@ func (q *Queries) GetAudioFileByPath(ctx context.Context, filePath string) (Audi &i.Channels, &i.Bitrate, &i.FileSize, + &i.Basename, ) return i, err } @@ -403,7 +409,7 @@ func (q *Queries) GetAudioFilesByReleaseGroup(ctx context.Context, releaseGroupI } const getAudioFilesNeedingMetadata = `-- name: GetAudioFilesNeedingMetadata :many -SELECT id, file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size FROM audio_files +SELECT id, file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename FROM audio_files WHERE recording_id = 0 ` @@ -427,6 +433,7 @@ func (q *Queries) GetAudioFilesNeedingMetadata(ctx context.Context) ([]AudioFile &i.Channels, &i.Bitrate, &i.FileSize, + &i.Basename, ); err != nil { return nil, err } @@ -492,9 +499,71 @@ func (q *Queries) GetTrackMetadataByPath(ctx context.Context, filePath string) ( return i, err } +const searchAudioFilesByBasename = `-- name: SearchAudioFilesByBasename :many +SELECT + af.file_path, + af.length_milliseconds, + COALESCE(r.name, '') AS title, + COALESCE(ac.text, '') AS artist, + COALESCE(rg.name, '') AS album +FROM audio_files af +LEFT JOIN recordings r ON af.recording_id = r.id +LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id +LEFT JOIN ( + SELECT recording_id, MIN(release_group_id) AS release_group_id + FROM release_group_recordings + GROUP BY recording_id +) rgr ON r.id = rgr.recording_id +LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id +WHERE af.basename = ? +LIMIT ? +` + +type SearchAudioFilesByBasenameParams struct { + Basename string + Limit int64 +} + +type SearchAudioFilesByBasenameRow struct { + FilePath string + LengthMilliseconds int64 + Title string + Artist string + Album string +} + +func (q *Queries) SearchAudioFilesByBasename(ctx context.Context, arg SearchAudioFilesByBasenameParams) ([]SearchAudioFilesByBasenameRow, error) { + rows, err := q.db.QueryContext(ctx, searchAudioFilesByBasename, arg.Basename, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []SearchAudioFilesByBasenameRow + for rows.Next() { + var i SearchAudioFilesByBasenameRow + if err := rows.Scan( + &i.FilePath, + &i.LengthMilliseconds, + &i.Title, + &i.Artist, + &i.Album, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const updateAudioFile = `-- name: UpdateAudioFile :exec UPDATE audio_files -SET file_path = ?, length_milliseconds = ?, file_type_id = ?, recording_id = ?, sample_rate = ?, bit_depth = ?, channels = ?, bitrate = ?, file_size = ? +SET file_path = ?, length_milliseconds = ?, file_type_id = ?, recording_id = ?, sample_rate = ?, bit_depth = ?, channels = ?, bitrate = ?, file_size = ?, basename = ? WHERE id = ? ` @@ -508,6 +577,7 @@ type UpdateAudioFileParams struct { Channels int64 Bitrate int64 FileSize int64 + Basename string ID int64 } @@ -522,6 +592,7 @@ func (q *Queries) UpdateAudioFile(ctx context.Context, arg UpdateAudioFileParams arg.Channels, arg.Bitrate, arg.FileSize, + arg.Basename, arg.ID, ) return err diff --git a/backend/database/sql/sqlcgen/genres.sql.go b/backend/database/sql/sqlcgen/genres.sql.go index c022128..07082d9 100644 --- a/backend/database/sql/sqlcgen/genres.sql.go +++ b/backend/database/sql/sqlcgen/genres.sql.go @@ -7,6 +7,7 @@ package sqlcgen import ( "context" + "database/sql" ) const createRecordingGenre = `-- name: CreateRecordingGenre :exec @@ -52,6 +53,42 @@ func (q *Queries) DeleteRecordingGenres(ctx context.Context, recordingID int64) return err } +const getAllGenresWithCounts = `-- name: GetAllGenresWithCounts :many +SELECT g.name, COUNT(rg.recording_id) AS track_count +FROM genres g +JOIN recording_genres rg ON g.id = rg.genre_id +GROUP BY g.id, g.name +ORDER BY g.name +` + +type GetAllGenresWithCountsRow struct { + Name string + TrackCount int64 +} + +func (q *Queries) GetAllGenresWithCounts(ctx context.Context) ([]GetAllGenresWithCountsRow, error) { + rows, err := q.db.QueryContext(ctx, getAllGenresWithCounts) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetAllGenresWithCountsRow + for rows.Next() { + var i GetAllGenresWithCountsRow + if err := rows.Scan(&i.Name, &i.TrackCount); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getGenresByRecordingID = `-- name: GetGenresByRecordingID :many SELECT g.id, g.name FROM genres g @@ -82,6 +119,106 @@ func (q *Queries) GetGenresByRecordingID(ctx context.Context, recordingID int64) return items, nil } +const getTracksByGenre = `-- name: GetTracksByGenre :many +SELECT + af.file_path, + af.length_milliseconds, + COALESCE(r.name, '') AS title, + COALESCE(ac.text, '') AS artist_name, + r.track_number, + r.disc_number, + COALESCE(rlg.name, '') AS album, + CAST(COALESCE( + (SELECT GROUP_CONCAT(g2.name, '||') + FROM recording_genres rg2 + JOIN genres g2 ON rg2.genre_id = g2.id + WHERE rg2.recording_id = r.id), + '' + ) AS TEXT) AS genre, + COALESCE(r.year, 0) AS year, + COALESCE(r.composer, '') AS composer, + COALESCE(ft.extension, '') AS file_type, + af.sample_rate, + af.bit_depth, + af.channels, + af.bitrate, + af.file_size +FROM genres g +JOIN recording_genres rg ON g.id = rg.genre_id +JOIN recordings r ON rg.recording_id = r.id +JOIN audio_files af ON af.recording_id = r.id +JOIN artist_credit ac ON r.artist_credit_id = ac.id +LEFT JOIN ( + SELECT recording_id, + MIN(release_group_id) AS release_group_id + FROM release_group_recordings + GROUP BY recording_id +) rgr ON r.id = rgr.recording_id +LEFT JOIN release_groups rlg ON rgr.release_group_id = rlg.id +LEFT JOIN file_types ft ON af.file_type_id = ft.id +WHERE g.name = ? +ORDER BY r.name +` + +type GetTracksByGenreRow struct { + FilePath string + LengthMilliseconds int64 + Title string + ArtistName string + TrackNumber sql.NullInt64 + DiscNumber sql.NullInt64 + Album string + Genre string + Year int64 + Composer string + FileType string + SampleRate int64 + BitDepth int64 + Channels int64 + Bitrate int64 + FileSize int64 +} + +func (q *Queries) GetTracksByGenre(ctx context.Context, name string) ([]GetTracksByGenreRow, error) { + rows, err := q.db.QueryContext(ctx, getTracksByGenre, name) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetTracksByGenreRow + for rows.Next() { + var i GetTracksByGenreRow + if err := rows.Scan( + &i.FilePath, + &i.LengthMilliseconds, + &i.Title, + &i.ArtistName, + &i.TrackNumber, + &i.DiscNumber, + &i.Album, + &i.Genre, + &i.Year, + &i.Composer, + &i.FileType, + &i.SampleRate, + &i.BitDepth, + &i.Channels, + &i.Bitrate, + &i.FileSize, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const upsertGenre = `-- name: UpsertGenre :one INSERT INTO genres (name) VALUES (?) ON CONFLICT(name) DO UPDATE SET name = name diff --git a/backend/database/sql/sqlcgen/models.go b/backend/database/sql/sqlcgen/models.go index 4fe671e..1b389f5 100644 --- a/backend/database/sql/sqlcgen/models.go +++ b/backend/database/sql/sqlcgen/models.go @@ -36,6 +36,7 @@ type AudioFile struct { Channels int64 Bitrate int64 FileSize int64 + Basename string } type CoverArt struct { @@ -128,3 +129,10 @@ type ReleaseGroupRecording struct { TrackNumber sql.NullInt64 DiscNumber sql.NullInt64 } + +type SearchIndex struct { + FilePath string + Title string + Artist string + Album string +} diff --git a/backend/library/library.go b/backend/library/library.go index c9c7961..5692da8 100644 --- a/backend/library/library.go +++ b/backend/library/library.go @@ -485,6 +485,17 @@ func (l *Library) Scan() (*ScanMetrics, error) { return true } + // Remove from FTS5 search index. + if err := l.db.DeleteSearchIndex( + audioFile.ID, + ); err != nil { + l.logger.Warn( + "failed to delete FTS entry for orphan", + "id", audioFile.ID, + "err", err, + ) + } + removed.Add(1) return true @@ -652,7 +663,7 @@ func (l *Library) commitBatch( if result.needsUpdate { saveErr = l.updateAudioFileMetadata( - txq, cache, metrics, *result, + txq, tx, cache, metrics, *result, thumbChan, ) if saveErr == nil { @@ -660,7 +671,7 @@ func (l *Library) commitBatch( } } else { saveErr = l.saveAudioFile( - txq, cache, metrics, *result, + txq, tx, cache, metrics, *result, thumbChan, ) if saveErr == nil { @@ -692,6 +703,7 @@ func (l *Library) commitBatch( // saveAudioFile writes audio file metadata to the database (new files). func (l *Library) saveAudioFile( q *sqlcgen.Queries, + tx *sql.Tx, cache *entityCache, metrics *ScanMetrics, result importResult, @@ -722,7 +734,14 @@ func (l *Library) saveAudioFile( props = &metadata.AudioProperties{} } - if _, err := q.CreateAudioFile( + tags := result.tags + if tags == nil { + tags = &metadata.TrackMetadata{} + } + + basename := filepath.Base(result.absolutePath) + + af, err := q.CreateAudioFile( l.ctx, sqlcgen.CreateAudioFileParams{ FilePath: result.absolutePath, LengthMilliseconds: result.lengthMillis, @@ -738,12 +757,37 @@ func (l *Library) saveAudioFile( Channels: int64(props.Channels), Bitrate: int64(props.Bitrate), FileSize: props.FileSize, - }); err != nil { + Basename: basename, + }) + if err != nil { return fmt.Errorf( "could not save audio file to db: %w", err, ) } + // Index in FTS5 search_index. + title := l.getRecordingName(tags, result.absolutePath) + + artistName := tags.Artist + if artistName == "" { + artistName = "Unknown Artist" + } + + album := tags.Album + + if _, err := tx.ExecContext( + l.ctx, + `INSERT INTO search_index(rowid, file_path, title, artist, album) + VALUES (?, ?, ?, ?, ?)`, + af.ID, result.absolutePath, title, artistName, album, + ); err != nil { + l.logger.Warn( + "could not index audio file in FTS", + "path", result.absolutePath, + "err", err, + ) + } + l.logger.Debug( "added audio file to library", "path", result.absolutePath, @@ -755,6 +799,7 @@ func (l *Library) saveAudioFile( // updateAudioFileMetadata updates an existing audio file with extracted metadata. func (l *Library) updateAudioFileMetadata( q *sqlcgen.Queries, + tx *sql.Tx, cache *entityCache, metrics *ScanMetrics, result importResult, @@ -794,6 +839,50 @@ func (l *Library) updateAudioFileMetadata( ) } + // Index in FTS5 search_index (delete old entry, insert new). + tags := result.tags + if tags == nil { + tags = &metadata.TrackMetadata{} + } + + title := l.getRecordingName(tags, result.absolutePath) + + artistName := tags.Artist + if artistName == "" { + artistName = "Unknown Artist" + } + + album := tags.Album + + if _, err := tx.ExecContext( + l.ctx, + `DELETE FROM search_index WHERE rowid = ?`, + result.existingFileID, + ); err != nil { + l.logger.Warn( + "could not remove old FTS entry", + "id", result.existingFileID, + "err", err, + ) + } + + if _, err := tx.ExecContext( + l.ctx, + `INSERT INTO search_index(rowid, file_path, title, artist, album) + VALUES (?, ?, ?, ?, ?)`, + result.existingFileID, + result.absolutePath, + title, + artistName, + album, + ); err != nil { + l.logger.Warn( + "could not index updated audio file in FTS", + "path", result.absolutePath, + "err", err, + ) + } + l.logger.Debug( "updated audio file metadata", "path", result.absolutePath, diff --git a/backend/library/query.go b/backend/library/query.go index 0bf93a0..016916d 100644 --- a/backend/library/query.go +++ b/backend/library/query.go @@ -1,6 +1,7 @@ package library import ( + "database/sql" "errors" "fmt" "path/filepath" @@ -48,6 +49,39 @@ func splitGenres(concatenated string) []string { return strings.Split(concatenated, genreDelimiter) } +// mapTrackRow converts raw database column values into a Track. +// This is shared by GetAllTracks, SearchTracks, and GetTracksByGenre +// to avoid tripling the row-mapping code. +func mapTrackRow( + filePath string, + lengthMs int64, + title, artistName string, + trackNumber, discNumber sql.NullInt64, + album, genre string, + year int64, + composer, fileType string, + sampleRate, bitDepth, channels, bitrate, fileSize int64, +) Track { + return Track{ + TrackName: title, + ArtistName: artistName, + TrackLength: strconv.FormatInt(lengthMs, 10), + FilePath: filePath, + TrackNumber: trackNumber.Int64, + DiscNumber: discNumber.Int64, + Album: album, + Genre: splitGenres(genre), + Year: year, + Composer: composer, + FileType: fileType, + SampleRate: sampleRate, + BitDepth: bitDepth, + Channels: channels, + Bitrate: bitrate, + FileSize: fileSize, + } +} + // Artist represents an artist in the library. type Artist struct { ID int64 @@ -91,28 +125,24 @@ func (l *Library) GetAllTracks() ([]Track, error) { tracks := make([]Track, 0, len(rows)) for _, row := range rows { - track := Track{ - TrackName: row.Title, - ArtistName: row.ArtistName, - TrackLength: strconv.FormatInt( - row.LengthMilliseconds, 10, - ), - FilePath: row.FilePath, - TrackNumber: row.TrackNumber.Int64, - DiscNumber: row.DiscNumber.Int64, - Album: row.Album, - Genre: splitGenres(row.Genre), - Year: row.Year, - Composer: row.Composer, - FileType: row.FileType, - SampleRate: row.SampleRate, - BitDepth: row.BitDepth, - Channels: row.Channels, - Bitrate: row.Bitrate, - FileSize: row.FileSize, - } - - tracks = append(tracks, track) + tracks = append(tracks, mapTrackRow( + row.FilePath, + row.LengthMilliseconds, + row.Title, + row.ArtistName, + row.TrackNumber, + row.DiscNumber, + row.Album, + row.Genre, + row.Year, + row.Composer, + row.FileType, + row.SampleRate, + row.BitDepth, + row.Channels, + row.Bitrate, + row.FileSize, + )) } l.logger.Info("formatted tracks", "count", len(tracks)) @@ -120,6 +150,56 @@ func (l *Library) GetAllTracks() ([]Track, error) { return tracks, nil } +// searchTrackLimit is the maximum number of results returned by +// a full-text search. +const searchTrackLimit = 200 + +// SearchTracks performs an FTS5 full-text search and returns +// matching tracks with full metadata. +func (l *Library) SearchTracks( + query string, +) ([]Track, error) { + rows, err := l.db.SearchFTSTracks( + query, searchTrackLimit, + ) + if err != nil { + l.logger.Error( + "FTS track search failed", + "query", query, + "error", err, + ) + + return nil, fmt.Errorf( + "search tracks failed: %w", err, + ) + } + + tracks := make([]Track, 0, len(rows)) + + for _, row := range rows { + tracks = append(tracks, mapTrackRow( + row.FilePath, + row.LengthMilliseconds, + row.Title, + row.ArtistName, + row.TrackNumber, + row.DiscNumber, + row.Album, + row.Genre, + row.Year, + row.Composer, + row.FileType, + row.SampleRate, + row.BitDepth, + row.Channels, + row.Bitrate, + row.FileSize, + )) + } + + return tracks, nil +} + // GetAlbumTracks returns all tracks for a given album (release group), ordered by disc and track number. func (l *Library) GetAlbumTracks(albumID int64) ([]Track, error) { rows, err := l.db.Queries.GetAudioFilesByReleaseGroup(l.ctx, albumID) @@ -280,3 +360,84 @@ func (l *Library) GetAlbumsByArtist( return albums, nil } + +// GenreWithCount holds a genre name and its associated track count. +type GenreWithCount struct { + Name string `json:"Name"` + TrackCount int64 `json:"TrackCount"` +} + +// GetTracksByGenre returns all tracks tagged with the given genre. +func (l *Library) GetTracksByGenre( + genreName string, +) ([]Track, error) { + rows, err := l.db.Queries.GetTracksByGenre( + l.ctx, genreName, + ) + if err != nil { + l.logger.Error( + "could not retrieve tracks for genre", + "genre", genreName, + "error", err, + ) + + return nil, fmt.Errorf( + "could not get tracks for genre: %w", err, + ) + } + + tracks := make([]Track, 0, len(rows)) + + for _, row := range rows { + tracks = append(tracks, mapTrackRow( + row.FilePath, + row.LengthMilliseconds, + row.Title, + row.ArtistName, + row.TrackNumber, + row.DiscNumber, + row.Album, + row.Genre, + row.Year, + row.Composer, + row.FileType, + row.SampleRate, + row.BitDepth, + row.Channels, + row.Bitrate, + row.FileSize, + )) + } + + return tracks, nil +} + +// GetAllGenresWithCounts returns all genres with their track counts. +func (l *Library) GetAllGenresWithCounts() ( + []GenreWithCount, error, +) { + rows, err := l.db.Queries.GetAllGenresWithCounts( + l.ctx, + ) + if err != nil { + l.logger.Error( + "could not retrieve genres with counts", + "error", err, + ) + + return nil, fmt.Errorf( + "could not get genres: %w", err, + ) + } + + genres := make([]GenreWithCount, 0, len(rows)) + + for _, row := range rows { + genres = append(genres, GenreWithCount{ + Name: row.Name, + TrackCount: row.TrackCount, + }) + } + + return genres, nil +} diff --git a/backend/library/rescan.go b/backend/library/rescan.go index acf0d10..3e4e20d 100644 --- a/backend/library/rescan.go +++ b/backend/library/rescan.go @@ -160,6 +160,15 @@ func (l *Library) clearLibraryTables() error { ) } + // Clear FTS5 search index. + if _, err := tx.ExecContext( + l.ctx, `DELETE FROM search_index`, + ); err != nil { + return fmt.Errorf( + "could not clear search index: %w", err, + ) + } + if err := tx.Commit(); err != nil { return fmt.Errorf( "could not commit library clear transaction: %w", err, diff --git a/backend/playlist/m3u.go b/backend/playlist/m3u.go index e2c61ed..52b66ac 100644 --- a/backend/playlist/m3u.go +++ b/backend/playlist/m3u.go @@ -19,7 +19,6 @@ const ( ) var ( - errInvalidM3U = errors.New("invalid M3U file: missing #EXTM3U header") errEmptyM3UFile = errors.New("M3U file is empty") errPlaylistDirNil = errors.New("playlists directory path is empty") ) @@ -132,15 +131,15 @@ func parseM3U8(filePath string) (parsedPlaylist, error) { continue } - // Check header. + // Check header. If the first non-empty line is not + // #EXTM3U, treat the file as a simple M3U (just + // path lines) and fall through to process normally. if !headerSeen { - if line == m3uHeader { - headerSeen = true + headerSeen = true + if line == m3uHeader { continue } - - return parsedPlaylist{}, errInvalidM3U } // Playlist name directive. @@ -300,11 +299,16 @@ func findPlaylistFile( ) } - if len(matches) == 0 { - return "", nil + // Filter matches to ensure the extracted ID matches the + // target. The glob pattern "1-*.m3u8" also matches + // "10-foo.m3u8", "11-bar.m3u8", etc. + for _, m := range matches { + if extractPlaylistID(m) == id { + return m, nil + } } - return matches[0], nil + return "", nil } // removeOldPlaylistFile removes an old playlist file for the given @@ -409,6 +413,74 @@ func extractPlaylistID(filePath string) int64 { return id } +// removeM3UEntries removes entries from a slice whose resolved +// absolute paths appear in the target set. +func removeM3UEntries( + entries []m3uEntry, + targetAbsPaths map[string]struct{}, + libraryRoot string, +) []m3uEntry { + result := make([]m3uEntry, 0, len(entries)) + + for _, e := range entries { + absPath := toAbsolutePath( + e.RelativePath, libraryRoot, + ) + if _, remove := targetAbsPaths[absPath]; remove { + continue + } + + result = append(result, e) + } + + return result +} + +// replaceM3UEntryPaths replaces the relative paths of entries +// whose resolved absolute paths match keys in the replacements +// map. Values are new relative paths. +func replaceM3UEntryPaths( + entries []m3uEntry, + replacements map[string]string, + libraryRoot string, +) []m3uEntry { + result := make([]m3uEntry, len(entries)) + + for i, e := range entries { + result[i] = e + + absPath := toAbsolutePath( + e.RelativePath, libraryRoot, + ) + + if newRel, ok := replacements[absPath]; ok { + result[i].RelativePath = newRel + } + } + + return result +} + +// findM3UEntry finds the M3U entry whose resolved absolute path +// matches the given target path. Returns the entry and its index, +// or -1 if not found. +func findM3UEntry( + entries []m3uEntry, + targetAbsPath string, + libraryRoot string, +) (m3uEntry, int) { + for i, e := range entries { + absPath := toAbsolutePath( + e.RelativePath, libraryRoot, + ) + if absPath == targetAbsPath { + return e, i + } + } + + return m3uEntry{}, -1 +} + // displayTitle builds an EXTINF display title from artist and title. func displayTitle(artist, title string) string { artist = strings.TrimSpace(artist) diff --git a/backend/playlist/m3u_test.go b/backend/playlist/m3u_test.go index 2406f65..f9c10f6 100644 --- a/backend/playlist/m3u_test.go +++ b/backend/playlist/m3u_test.go @@ -201,25 +201,24 @@ func TestWriteM3U8EmptyDir(t *testing.T) { } } -func TestParseM3U8InvalidFile(t *testing.T) { +func TestParseM3U8EmptyFile(t *testing.T) { t.Parallel() dir := t.TempDir() - badFile := filepath.Join(dir, "bad.m3u8") + emptyFile := filepath.Join(dir, "empty.m3u8") - // Write a file without the M3U header. err := os.WriteFile( - badFile, - []byte("just some text\n"), + emptyFile, + []byte(""), 0o644, ) if err != nil { t.Fatalf("could not write test file: %v", err) } - _, err = parseM3U8(badFile) + _, err = parseM3U8(emptyFile) if err == nil { - t.Fatal("expected error for invalid M3U file") + t.Fatal("expected error for empty M3U file") } } @@ -592,3 +591,272 @@ func TestParseExtInf(t *testing.T) { }) } } + +func TestFindPlaylistFileOverlappingIDs(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + + // Create playlists with IDs 1 and 10 — the glob + // pattern "1-*.m3u8" must not match "10-longer.m3u8". + if err := writeM3U8(dir, 1, "Short", nil); err != nil { + t.Fatalf("writeM3U8(1) error = %v", err) + } + + if err := writeM3U8(dir, 10, "Longer", nil); err != nil { + t.Fatalf("writeM3U8(10) error = %v", err) + } + + found, err := findPlaylistFile(dir, 1) + if err != nil { + t.Fatalf("findPlaylistFile(1) error = %v", err) + } + + if got := extractPlaylistID(found); got != 1 { + t.Errorf( + "findPlaylistFile(1) returned ID %d, want 1", + got, + ) + } + + found, err = findPlaylistFile(dir, 10) + if err != nil { + t.Fatalf("findPlaylistFile(10) error = %v", err) + } + + if got := extractPlaylistID(found); got != 10 { + t.Errorf( + "findPlaylistFile(10) returned ID %d, want 10", + got, + ) + } +} + +func TestParseM3U8SimpleFormat(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + simpleFile := filepath.Join(dir, "simple.m3u") + + // Write a simple M3U with no #EXTM3U header — just paths. + content := "Artist/Album/01 - Song.flac\nOther/Track.mp3\n" + + if err := os.WriteFile( + simpleFile, []byte(content), 0o644, + ); err != nil { + t.Fatalf("could not write test file: %v", err) + } + + parsed, err := parseM3U8(simpleFile) + if err != nil { + t.Fatalf("parseM3U8() error = %v", err) + } + + if len(parsed.Entries) != 2 { + t.Fatalf( + "parsed %d entries, want 2", + len(parsed.Entries), + ) + } + + if parsed.Entries[0].RelativePath != + "Artist/Album/01 - Song.flac" { + t.Errorf( + "entry[0].RelativePath = %q, want %q", + parsed.Entries[0].RelativePath, + "Artist/Album/01 - Song.flac", + ) + } + + if parsed.Entries[1].RelativePath != + "Other/Track.mp3" { + t.Errorf( + "entry[1].RelativePath = %q, want %q", + parsed.Entries[1].RelativePath, + "Other/Track.mp3", + ) + } +} + +func TestParseM3U8SimpleFormatWithComments(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + simpleFile := filepath.Join(dir, "commented.m3u") + + // Simple M3U with comment lines (no #EXTM3U header). + content := "# Generated by SomeApp\n" + + "Artist/Song.flac\n" + + "# Another comment\n" + + "Other/Track.mp3\n" + + if err := os.WriteFile( + simpleFile, []byte(content), 0o644, + ); err != nil { + t.Fatalf("could not write test file: %v", err) + } + + parsed, err := parseM3U8(simpleFile) + if err != nil { + t.Fatalf("parseM3U8() error = %v", err) + } + + if len(parsed.Entries) != 2 { + t.Fatalf( + "parsed %d entries, want 2", + len(parsed.Entries), + ) + } + + if parsed.Entries[0].RelativePath != + "Artist/Song.flac" { + t.Errorf( + "entry[0].RelativePath = %q, want %q", + parsed.Entries[0].RelativePath, + "Artist/Song.flac", + ) + } +} + +func TestRemoveM3UEntries(t *testing.T) { + t.Parallel() + + entries := []m3uEntry{ + {RelativePath: "Artist/Song1.flac"}, + {RelativePath: "Artist/Song2.flac"}, + {RelativePath: "Artist/Song3.flac"}, + } + + targets := map[string]struct{}{ + "/music/Artist/Song2.flac": {}, + } + + result := removeM3UEntries(entries, targets, "/music") + + if len(result) != 2 { + t.Fatalf("expected 2 entries, got %d", len(result)) + } + + if result[0].RelativePath != "Artist/Song1.flac" { + t.Errorf( + "entry[0] = %q, want %q", + result[0].RelativePath, + "Artist/Song1.flac", + ) + } + + if result[1].RelativePath != "Artist/Song3.flac" { + t.Errorf( + "entry[1] = %q, want %q", + result[1].RelativePath, + "Artist/Song3.flac", + ) + } +} + +func TestRemoveM3UEntriesAll(t *testing.T) { + t.Parallel() + + entries := []m3uEntry{ + {RelativePath: "Song.flac"}, + } + + targets := map[string]struct{}{ + "/music/Song.flac": {}, + } + + result := removeM3UEntries(entries, targets, "/music") + + if len(result) != 0 { + t.Errorf("expected 0 entries, got %d", len(result)) + } +} + +func TestReplaceM3UEntryPaths(t *testing.T) { + t.Parallel() + + entries := []m3uEntry{ + { + RelativePath: "old/path/song.flac", + DurationSec: 180, + DisplayTitle: "Song", + }, + { + RelativePath: "other/track.mp3", + DurationSec: 240, + DisplayTitle: "Track", + }, + } + + replacements := map[string]string{ + "/music/old/path/song.flac": "new/path/song.flac", + } + + result := replaceM3UEntryPaths( + entries, replacements, "/music", + ) + + if len(result) != 2 { + t.Fatalf("expected 2 entries, got %d", len(result)) + } + + if result[0].RelativePath != "new/path/song.flac" { + t.Errorf( + "entry[0].RelativePath = %q, want %q", + result[0].RelativePath, + "new/path/song.flac", + ) + } + + // Duration and title should be preserved. + if result[0].DurationSec != 180 { + t.Errorf( + "entry[0].DurationSec = %d, want 180", + result[0].DurationSec, + ) + } + + // Unchanged entry should remain the same. + if result[1].RelativePath != "other/track.mp3" { + t.Errorf( + "entry[1].RelativePath = %q, want %q", + result[1].RelativePath, + "other/track.mp3", + ) + } +} + +func TestFindM3UEntry(t *testing.T) { + t.Parallel() + + entries := []m3uEntry{ + {RelativePath: "Artist/Song1.flac"}, + {RelativePath: "Artist/Song2.flac"}, + {RelativePath: "Artist/Song3.flac"}, + } + + entry, idx := findM3UEntry( + entries, "/music/Artist/Song2.flac", "/music", + ) + + if idx != 1 { + t.Errorf("expected index 1, got %d", idx) + } + + if entry.RelativePath != "Artist/Song2.flac" { + t.Errorf( + "entry.RelativePath = %q, want %q", + entry.RelativePath, + "Artist/Song2.flac", + ) + } + + // Not found. + _, idx = findM3UEntry( + entries, "/music/Artist/Missing.flac", "/music", + ) + + if idx != -1 { + t.Errorf("expected index -1, got %d", idx) + } +} diff --git a/backend/playlist/match.go b/backend/playlist/match.go new file mode 100644 index 0000000..e6f61f4 --- /dev/null +++ b/backend/playlist/match.go @@ -0,0 +1,379 @@ +// Package playlist provides playlist management functionality. +package playlist + +import ( + "math" + "path/filepath" + "regexp" + "strings" + "unicode/utf8" +) + +// Scoring weights for candidate matching. +const ( + weightFilename = 0.50 + weightTitle = 0.30 + weightDuration = 0.10 + weightPathDirs = 0.10 + autoMatchMinimum = 0.85 +) + +// maxCandidates is the default limit for search results. +const maxCandidates = 20 + +// maxLibrarySearchResults is the limit for manual library search. +const maxLibrarySearchResults = 50 + +// durationToleranceClose is the duration difference in seconds +// considered a near-exact match. +const durationToleranceClose = 1 + +// durationToleranceMedium is the medium tolerance threshold. +const durationToleranceMedium = 5 + +// durationToleranceFar is the maximum tolerance before scoring +// drops to zero. +const durationToleranceFar = 15 + +// separatorPattern splits file paths and names on common +// separators: slashes, hyphens, underscores, spaces, dots. +var separatorPattern = regexp.MustCompile( + `[/\\\-_. ]+`, +) + +// trackNumberPattern matches leading track numbers like +// "01", "1", "01.", "01 -", etc. +var trackNumberPattern = regexp.MustCompile( + `^\d{1,3}[.\-\s]*$`, +) + +// phantomProfile pre-computes all derived data for a phantom +// track so that scoring multiple candidates avoids redundant +// string processing. +type phantomProfile struct { + baseLower string // lowercase basename + baseStem string // basename without extension + baseWords []string // significant words from stem + dirWords []string // significant words from dir path + displayLow string // lowercase display title + parsedArt string // parsed artist from display title + parsedTitle string // parsed title from display title + titleWords []string // significant words from display title + durationSec int // phantom duration in seconds +} + +// newPhantomProfile builds a phantomProfile from raw phantom +// data, performing all string splits and normalisation once. +func newPhantomProfile( + phantomPath string, + displayTitle string, + durationSec int, +) phantomProfile { + baseLower := strings.ToLower( + filepath.Base(phantomPath), + ) + baseStem := stripExtension(baseLower) + displayLow := strings.ToLower( + strings.TrimSpace(displayTitle), + ) + parsedArt, parsedTitle := parseDisplayTitle(displayLow) + + return phantomProfile{ + baseLower: baseLower, + baseStem: baseStem, + baseWords: significantWords(baseStem), + dirWords: pathDirWords(phantomPath), + displayLow: displayLow, + parsedArt: parsedArt, + parsedTitle: parsedTitle, + titleWords: significantWords(displayLow), + durationSec: durationSec, + } +} + +// scoreCandidate computes a match confidence (0.0-1.0) between +// a phantom track and a candidate library track. +func scoreCandidate( + pp phantomProfile, + candidatePath string, + candidateTitle string, + candidateArtist string, + candidateDurationMs int64, +) float64 { + fnScore := scoreFilename(pp, candidatePath) + titleScore := scoreTitleArtist( + pp, candidateTitle, candidateArtist, + ) + durScore := scoreDuration( + pp.durationSec, candidateDurationMs, + ) + dirScore := scorePathDirs(pp, candidatePath) + + // If duration is unknown, redistribute its weight to + // filename. + fnWeight := weightFilename + durWeight := weightDuration + + if pp.durationSec == 0 { + fnWeight += durWeight + durWeight = 0 + } + + return fnScore*fnWeight + + titleScore*weightTitle + + durScore*durWeight + + dirScore*weightPathDirs +} + +// scoreFilename compares the basenames of two file paths. +func scoreFilename( + pp phantomProfile, candidatePath string, +) float64 { + cBase := strings.ToLower( + filepath.Base(candidatePath), + ) + + // Exact basename match. + if pp.baseLower == cBase { + return 1.0 + } + + // Match ignoring extension. + cStem := stripExtension(cBase) + + if pp.baseStem == cStem { + return 0.8 + } + + // Check if all significant words from phantom stem appear + // in candidate stem. + cWords := significantWords(cStem) + + if len(pp.baseWords) == 0 { + return 0.0 + } + + return keywordOverlap(pp.baseWords, cWords) +} + +// scoreTitleArtist compares the phantom's EXTINF display title +// against the candidate's DB title and artist fields. +func scoreTitleArtist( + pp phantomProfile, + candidateTitle, candidateArtist string, +) float64 { + if pp.displayLow == "" { + return 0.0 + } + + candidateTitle = strings.ToLower( + strings.TrimSpace(candidateTitle), + ) + candidateArtist = strings.ToLower( + strings.TrimSpace(candidateArtist), + ) + + // Exact title match. + if pp.parsedTitle != "" && + pp.parsedTitle == candidateTitle { + if pp.parsedArt != "" && + pp.parsedArt == candidateArtist { + return 1.0 + } + + return 0.8 + } + + // Keyword overlap between display title and combined + // candidate metadata. + combined := candidateTitle + " " + candidateArtist + cWords := significantWords(combined) + + if len(pp.titleWords) == 0 { + return 0.0 + } + + return keywordOverlap(pp.titleWords, cWords) +} + +// scoreDuration computes a score based on duration proximity. +func scoreDuration( + phantomSec int, candidateMs int64, +) float64 { + if phantomSec == 0 || candidateMs == 0 { + return 0.0 + } + + diff := math.Abs( + float64(phantomSec) - float64(candidateMs)/1000.0, + ) + + switch { + case diff <= float64(durationToleranceClose): + return 1.0 + case diff <= float64(durationToleranceMedium): + return 0.8 + case diff <= float64(durationToleranceFar): + return 0.5 + default: + return 0.0 + } +} + +// scorePathDirs compares the directory components of two paths. +func scorePathDirs( + pp phantomProfile, candidatePath string, +) float64 { + if len(pp.dirWords) == 0 { + return 0.0 + } + + cDirs := pathDirWords(candidatePath) + + return keywordOverlap(pp.dirWords, cDirs) +} + +// parseDisplayTitle splits an EXTINF display title on " - " into +// (artist, title). If no separator is found, returns ("", full). +func parseDisplayTitle(dt string) (artist, title string) { + idx := strings.Index(dt, " - ") + if idx < 0 { + return "", dt + } + + return strings.TrimSpace(dt[:idx]), + strings.TrimSpace(dt[idx+3:]) +} + +// extractKeywords extracts meaningful search keywords from a file +// path by splitting on separators, removing track numbers, common +// noise words, and the file extension. +func extractKeywords(filePath string) []string { + // Remove extension. + stem := stripExtension(filePath) + + // Split on separators. + parts := separatorPattern.Split(stem, -1) + + var keywords []string + + for _, p := range parts { + p = strings.TrimSpace(p) + if p == "" { + continue + } + + // Skip pure track numbers. + if trackNumberPattern.MatchString(p) { + continue + } + + // Skip very short tokens. + if len(p) < 2 { + continue + } + + keywords = append(keywords, strings.ToLower(p)) + } + + return dedupStrings(keywords) +} + +// significantWords extracts meaningful lowercase words from a +// string, filtering out noise. +func significantWords(s string) []string { + parts := separatorPattern.Split(s, -1) + + var words []string + + for _, p := range parts { + p = strings.TrimSpace(p) + if p == "" { + continue + } + + // Skip pure track numbers. + if trackNumberPattern.MatchString(p) { + continue + } + + // Skip single characters. + if countRunes(p) < 2 { + continue + } + + words = append(words, strings.ToLower(p)) + } + + return words +} + +// pathDirWords extracts lowercase words from the directory +// portion of a path (excluding the filename). +func pathDirWords(filePath string) []string { + dir := filepath.Dir(filePath) + if dir == "." || dir == "/" { + return nil + } + + return significantWords(dir) +} + +// keywordOverlap calculates the proportion of source words that +// appear in target words (Jaccard-like, asymmetric). +func keywordOverlap(source, target []string) float64 { + if len(source) == 0 { + return 0.0 + } + + targetSet := make(map[string]struct{}, len(target)) + + for _, w := range target { + targetSet[w] = struct{}{} + } + + var matches int + + for _, w := range source { + if _, ok := targetSet[w]; ok { + matches++ + } + } + + return float64(matches) / float64(len(source)) +} + +// stripExtension removes the file extension from a path or +// filename. +func stripExtension(s string) string { + ext := filepath.Ext(s) + if ext == "" { + return s + } + + return s[:len(s)-len(ext)] +} + +// dedupStrings removes duplicate strings, preserving order. +func dedupStrings(ss []string) []string { + seen := make(map[string]struct{}, len(ss)) + + var result []string + + for _, s := range ss { + if _, ok := seen[s]; ok { + continue + } + + seen[s] = struct{}{} + + result = append(result, s) + } + + return result +} + +// countRunes returns the number of runes in a string. +func countRunes(s string) int { + return utf8.RuneCountInString(s) +} diff --git a/backend/playlist/match_test.go b/backend/playlist/match_test.go new file mode 100644 index 0000000..2471019 --- /dev/null +++ b/backend/playlist/match_test.go @@ -0,0 +1,411 @@ +package playlist + +import ( + "math" + "testing" +) + +func TestScoreCandidateExactFilename(t *testing.T) { + t.Parallel() + + pp := newPhantomProfile( + "/old/path/Artist/Album/01 - Song.flac", + "Artist - Song", + 243, + ) + + score := scoreCandidate( + pp, + "/new/path/Artist/Album/01 - Song.flac", + "Song", + "Artist", + 243000, + ) + + if score < 0.9 { + t.Errorf("expected score >= 0.9, got %f", score) + } +} + +func TestScoreCandidateNoMatch(t *testing.T) { + t.Parallel() + + pp := newPhantomProfile( + "/music/Artist/Album/01 - Song.flac", + "Artist - Song", + 243, + ) + + score := scoreCandidate( + pp, + "/music/Completely/Different/track.mp3", + "Other Title", + "Other Artist", + 180000, + ) + + if score > 0.3 { + t.Errorf("expected score <= 0.3, got %f", score) + } +} + +func TestScoreCandidateSameFilenameNewDir(t *testing.T) { + t.Parallel() + + // Common case: file moved to a different directory. + pp := newPhantomProfile( + "/music/Old Dir/Artist/01 - Song.flac", + "Artist - Song", + 243, + ) + + score := scoreCandidate( + pp, + "/music/New Dir/Artist/01 - Song.flac", + "Song", + "Artist", + 243000, + ) + + if score < 0.8 { + t.Errorf( + "expected score >= 0.8 for same filename, got %f", + score, + ) + } +} + +func TestScoreCandidateDurationOnly(t *testing.T) { + t.Parallel() + + // Very close duration, but different filenames. + score := scoreDuration(243, 243500) + if score < 0.8 { + t.Errorf( + "expected duration score >= 0.8 for ~0.5s diff, got %f", + score, + ) + } + + // Exact match. + score = scoreDuration(180, 180000) + if score != 1.0 { + t.Errorf( + "expected 1.0 for exact match, got %f", + score, + ) + } + + // Far apart. + score = scoreDuration(100, 200000) + if score != 0.0 { + t.Errorf( + "expected 0.0 for 100s diff, got %f", + score, + ) + } + + // Unknown duration. + score = scoreDuration(0, 180000) + if score != 0.0 { + t.Errorf( + "expected 0.0 for unknown, got %f", + score, + ) + } +} + +func TestScoreFilename(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + phantom string + cand string + minScore float64 + maxScore float64 + }{ + { + name: "exact match", + phantom: "/a/b/song.flac", + cand: "/c/d/song.flac", + minScore: 1.0, + maxScore: 1.0, + }, + { + name: "same stem different ext", + phantom: "/a/song.flac", + cand: "/b/song.mp3", + minScore: 0.7, + maxScore: 0.9, + }, + { + name: "completely different", + phantom: "/a/song.flac", + cand: "/b/other.mp3", + minScore: 0.0, + maxScore: 0.2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + pp := newPhantomProfile(tt.phantom, "", 0) + score := scoreFilename(pp, tt.cand) + + if score < tt.minScore || score > tt.maxScore { + t.Errorf( + "scoreFilename(%q, %q) = %f, want [%f, %f]", + tt.phantom, tt.cand, + score, tt.minScore, tt.maxScore, + ) + } + }) + } +} + +func TestScoreTitleArtist(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + display string + title string + artist string + minScore float64 + }{ + { + name: "exact match", + display: "Pink Floyd - Comfortably Numb", + title: "Comfortably Numb", + artist: "Pink Floyd", + minScore: 0.9, + }, + { + name: "title only match", + display: "Comfortably Numb", + title: "Comfortably Numb", + artist: "Pink Floyd", + minScore: 0.7, + }, + { + name: "no match", + display: "Something Else", + title: "Completely Different", + artist: "Other Artist", + minScore: 0.0, + }, + { + name: "empty display title", + display: "", + title: "Any Title", + artist: "Any Artist", + minScore: 0.0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + pp := newPhantomProfile( + "/dummy/path.flac", tt.display, 0, + ) + score := scoreTitleArtist( + pp, tt.title, tt.artist, + ) + + if score < tt.minScore { + t.Errorf( + "scoreTitleArtist(%q, %q, %q) = %f, want >= %f", + tt.display, tt.title, tt.artist, + score, tt.minScore, + ) + } + }) + } +} + +func TestExtractKeywords(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + path string + expected []string + }{ + { + name: "typical music path", + path: "/music/Pink Floyd/The Wall/03 - Another Brick in the Wall.flac", + expected: []string{ + "music", "pink", "floyd", "the", + "wall", "another", "brick", "in", + }, + }, + { + name: "simple filename", + path: "song.mp3", + expected: []string{"song"}, + }, + { + name: "track number stripped", + path: "01 - Song Title.flac", + expected: []string{"song", "title"}, + }, + { + name: "empty path", + path: "", + expected: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + result := extractKeywords(tt.path) + if !stringSliceEqual(result, tt.expected) { + t.Errorf( + "extractKeywords(%q) = %v, want %v", + tt.path, result, tt.expected, + ) + } + }) + } +} + +func TestParseDisplayTitle(t *testing.T) { + t.Parallel() + + tests := []struct { + input string + artist string + title string + }{ + { + input: "Artist - Title", + artist: "Artist", + title: "Title", + }, + { + input: "Just a Title", + artist: "", + title: "Just a Title", + }, + { + input: "", + artist: "", + title: "", + }, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + t.Parallel() + + artist, title := parseDisplayTitle(tt.input) + if artist != tt.artist || title != tt.title { + t.Errorf( + "parseDisplayTitle(%q) = (%q, %q), want (%q, %q)", + tt.input, artist, title, + tt.artist, tt.title, + ) + } + }) + } +} + +func TestKeywordOverlap(t *testing.T) { + t.Parallel() + + // Full overlap. + score := keywordOverlap( + []string{"a", "b", "c"}, + []string{"a", "b", "c", "d"}, + ) + + if score != 1.0 { + t.Errorf("expected 1.0, got %f", score) + } + + // Partial overlap. + score = keywordOverlap( + []string{"a", "b", "c"}, + []string{"a", "d", "e"}, + ) + + expected := 1.0 / 3.0 + if math.Abs(score-expected) > 0.01 { + t.Errorf("expected ~%f, got %f", expected, score) + } + + // No overlap. + score = keywordOverlap( + []string{"a", "b"}, + []string{"c", "d"}, + ) + + if score != 0.0 { + t.Errorf("expected 0.0, got %f", score) + } + + // Empty source. + score = keywordOverlap(nil, []string{"a"}) + if score != 0.0 { + t.Errorf("expected 0.0 for empty source, got %f", score) + } +} + +func TestSortCandidatesByScore(t *testing.T) { + t.Parallel() + + candidates := []CandidateTrack{ + {FilePath: "a", Score: 0.3}, + {FilePath: "b", Score: 0.9}, + {FilePath: "c", Score: 0.6}, + } + + sortCandidatesByScore(candidates) + + if candidates[0].FilePath != "b" { + t.Errorf( + "expected first candidate to be 'b', got %q", + candidates[0].FilePath, + ) + } + + if candidates[1].FilePath != "c" { + t.Errorf( + "expected second candidate to be 'c', got %q", + candidates[1].FilePath, + ) + } + + if candidates[2].FilePath != "a" { + t.Errorf( + "expected third candidate to be 'a', got %q", + candidates[2].FilePath, + ) + } +} + +// stringSliceEqual compares two string slices. +func stringSliceEqual(a, b []string) bool { + if len(a) == 0 && len(b) == 0 { + return true + } + + if len(a) != len(b) { + return false + } + + for i := range a { + if a[i] != b[i] { + return false + } + } + + return true +} diff --git a/backend/playlist/playlist.go b/backend/playlist/playlist.go index a566538..95c6532 100644 --- a/backend/playlist/playlist.go +++ b/backend/playlist/playlist.go @@ -8,6 +8,7 @@ import ( "log/slog" "os" "path/filepath" + "slices" "strconv" "strings" @@ -67,6 +68,32 @@ type WithTracks struct { Tracks []Track `json:"Tracks"` } +// CandidateTrack represents a potential library match for a +// phantom track. +type CandidateTrack struct { + FilePath string `json:"FilePath"` + Title string `json:"Title"` + Artist string `json:"Artist"` + Album string `json:"Album"` + Duration string `json:"Duration"` + Score float64 `json:"Score"` +} + +// PhantomMatch represents a high-confidence pairing of a phantom +// track to a library track. +type PhantomMatch struct { + PhantomPath string `json:"PhantomPath"` + PhantomTitle string `json:"PhantomTitle"` + Candidate CandidateTrack `json:"Candidate"` +} + +// PhantomSearchResult contains auto-matched pairs and remaining +// unmatched phantom paths for a batch search operation. +type PhantomSearchResult struct { + AutoMatched []PhantomMatch `json:"AutoMatched"` + Unmatched []string `json:"Unmatched"` +} + // Service manages playlist operations. type Service struct { ctx context.Context @@ -679,9 +706,10 @@ func (s *Service) ImportPlaylist( var ( resolved int unresolved int + position int ) - for i, entry := range parsed.Entries { + for _, entry := range parsed.Entries { absPath := toAbsolutePath( entry.RelativePath, libraryRoot, ) @@ -701,7 +729,7 @@ func (s *Service) ImportPlaylist( sqlcgen.AddPlaylistTrackParams{ PlaylistID: created.ID, AudioFileID: audioFile.ID, - Position: int64(i), + Position: int64(position), }, ) if addErr != nil { @@ -715,6 +743,7 @@ func (s *Service) ImportPlaylist( continue } + position++ resolved++ } @@ -834,7 +863,9 @@ func (s *Service) restoreSinglePlaylist( return 0, 0 } - for i, entry := range parsed.Entries { + var position int + + for _, entry := range parsed.Entries { absPath := toAbsolutePath( entry.RelativePath, libraryRoot, ) @@ -853,7 +884,7 @@ func (s *Service) restoreSinglePlaylist( sqlcgen.AddPlaylistTrackParams{ PlaylistID: playlistID, AudioFileID: audioFile.ID, - Position: int64(i), + Position: int64(position), }, ) if addErr != nil { @@ -867,6 +898,7 @@ func (s *Service) restoreSinglePlaylist( continue } + position++ restored++ } @@ -1196,3 +1228,538 @@ func (s *Service) migrateExistingPlaylists() { ) } } + +// ================================================================= +// Phantom track resolution +// ================================================================= + +// FindPhantomMatches searches the library for matches for the +// given phantom file paths. High-confidence matches are returned +// as auto-matched pairs; the rest remain in the unmatched list. +func (s *Service) FindPhantomMatches( + playlistID int64, + phantomPaths []string, +) (PhantomSearchResult, error) { + if len(phantomPaths) == 0 { + return PhantomSearchResult{}, nil + } + + dir, err := s.playlistsDir() + if err != nil { + return PhantomSearchResult{}, fmt.Errorf( + "could not get playlists dir: %w", err, + ) + } + + libraryRoot := s.getLibraryRoot() + + // Load M3U8 entries for display title / duration data. + m3uPath, err := findPlaylistFile(dir, playlistID) + if err != nil { + return PhantomSearchResult{}, fmt.Errorf( + "could not find playlist file: %w", err, + ) + } + + var entries []m3uEntry + + if m3uPath != "" { + parsed, parseErr := parseM3U8(m3uPath) + if parseErr == nil { + entries = parsed.Entries + } + } + + // Build a lookup from absolute path to M3U entry. + entryByPath := make(map[string]m3uEntry, len(entries)) + + for _, e := range entries { + absPath := toAbsolutePath( + e.RelativePath, libraryRoot, + ) + entryByPath[absPath] = e + } + + // Track which candidates have been claimed by auto-match + // so we don't assign the same candidate to two phantoms. + claimed := make(map[string]struct{}) + + var result PhantomSearchResult + + for _, phantomPath := range phantomPaths { + entry := entryByPath[phantomPath] + candidates := s.searchCandidates( + phantomPath, entry, + ) + + matched := false + + for _, c := range candidates { + if _, taken := claimed[c.FilePath]; taken { + continue + } + + if c.Score >= autoMatchMinimum { + result.AutoMatched = append( + result.AutoMatched, + PhantomMatch{ + PhantomPath: phantomPath, + PhantomTitle: entry.DisplayTitle, + Candidate: c, + }, + ) + + claimed[c.FilePath] = struct{}{} + matched = true + + break + } + } + + if !matched { + result.Unmatched = append( + result.Unmatched, phantomPath, + ) + } + } + + return result, nil +} + +// GetPhantomCandidates returns scored candidate matches for a +// single phantom track. +func (s *Service) GetPhantomCandidates( + playlistID int64, + phantomPath string, +) ([]CandidateTrack, error) { + dir, err := s.playlistsDir() + if err != nil { + return nil, fmt.Errorf( + "could not get playlists dir: %w", err, + ) + } + + libraryRoot := s.getLibraryRoot() + + // Find the M3U entry for this phantom. + m3uPath, err := findPlaylistFile(dir, playlistID) + if err != nil { + return nil, fmt.Errorf( + "could not find playlist file: %w", err, + ) + } + + var entry m3uEntry + + if m3uPath != "" { + parsed, parseErr := parseM3U8(m3uPath) + if parseErr == nil { + entry, _ = findM3UEntry( + parsed.Entries, phantomPath, libraryRoot, + ) + } + } + + return s.searchCandidates( + phantomPath, entry, + ), nil +} + +// SearchLibrary searches the entire library by a free-text query +// for manual phantom resolution. +func (s *Service) SearchLibrary( + query string, +) ([]CandidateTrack, error) { + trimmed := strings.TrimSpace(query) + if trimmed == "" { + return []CandidateTrack{}, nil + } + + rows, err := s.db.SearchFTS( + trimmed, maxLibrarySearchResults, + ) + if err != nil { + return nil, fmt.Errorf( + "library search failed: %w", err, + ) + } + + candidates := make([]CandidateTrack, 0, len(rows)) + + for _, row := range rows { + candidates = append(candidates, CandidateTrack{ + FilePath: row.FilePath, + Title: row.Title, + Artist: row.Artist, + Album: row.Album, + Duration: strconv.FormatInt( + row.LengthMilliseconds, 10, + ), + }) + } + + return candidates, nil +} + +// ResolvePhantomTracks replaces phantom entries in a playlist +// with real library tracks. The matches map keys are phantom +// absolute paths and values are resolved absolute paths. +func (s *Service) ResolvePhantomTracks( + playlistID int64, + matches map[string]string, +) error { + if len(matches) == 0 { + return nil + } + + dir, err := s.playlistsDir() + if err != nil { + return fmt.Errorf( + "could not get playlists dir: %w", err, + ) + } + + libraryRoot := s.getLibraryRoot() + + m3uPath, err := findPlaylistFile(dir, playlistID) + if err != nil || m3uPath == "" { + return fmt.Errorf( + "could not find M3U8 file for playlist %d: %w", + playlistID, err, + ) + } + + parsed, err := parseM3U8(m3uPath) + if err != nil { + return fmt.Errorf( + "could not parse M3U8: %w", err, + ) + } + + // Get next available DB position. + nextPos, err := s.db.Queries.GetNextPlaylistTrackPosition( + s.db.Ctx, playlistID, + ) + if err != nil { + return fmt.Errorf( + "could not get next position: %w", err, + ) + } + + // Build M3U path replacements and insert DB rows. + pathReplacements := make( + map[string]string, len(matches), + ) + + var resolved int + + for phantomAbs, resolvedAbs := range matches { + audioFile, lookupErr := s.db.Queries.GetAudioFileByPath( + s.db.Ctx, resolvedAbs, + ) + if lookupErr != nil { + s.logger.Warn( + "Resolved path not found in library", + "phantomPath", phantomAbs, + "resolvedPath", resolvedAbs, + "err", lookupErr, + ) + + continue + } + + _, addErr := s.db.Queries.AddPlaylistTrack( + s.db.Ctx, + sqlcgen.AddPlaylistTrackParams{ + PlaylistID: playlistID, + AudioFileID: audioFile.ID, + Position: nextPos + int64(resolved), + }, + ) + if addErr != nil { + s.logger.Warn( + "Could not add resolved track", + "playlistId", playlistID, + "path", resolvedAbs, + "err", addErr, + ) + + continue + } + + newRel := toRelativePath(resolvedAbs, libraryRoot) + pathReplacements[phantomAbs] = newRel + resolved++ + } + + // Rewrite the M3U8 with updated paths. + if resolved > 0 { + updated := replaceM3UEntryPaths( + parsed.Entries, pathReplacements, libraryRoot, + ) + + playlist, nameErr := s.db.Queries.GetPlaylist( + s.db.Ctx, playlistID, + ) + if nameErr != nil { + return fmt.Errorf( + "could not get playlist name: %w", nameErr, + ) + } + + if writeErr := writeM3U8( + dir, playlistID, playlist.Name, updated, + ); writeErr != nil { + return fmt.Errorf( + "could not rewrite M3U8: %w", writeErr, + ) + } + } + + s.logger.Info( + "Phantom tracks resolved", + "playlistId", playlistID, + "resolved", resolved, + "requested", len(matches), + ) + + s.emitEvent(events.PlaylistTracksChanged, playlistID) + + return nil +} + +// RemovePhantomTracks removes phantom entries from a playlist's +// M3U8 file. Since phantom tracks have no DB rows, only the +// M3U8 file is modified. +func (s *Service) RemovePhantomTracks( + playlistID int64, + phantomPaths []string, +) error { + if len(phantomPaths) == 0 { + return nil + } + + dir, err := s.playlistsDir() + if err != nil { + return fmt.Errorf( + "could not get playlists dir: %w", err, + ) + } + + libraryRoot := s.getLibraryRoot() + + m3uPath, err := findPlaylistFile(dir, playlistID) + if err != nil || m3uPath == "" { + return fmt.Errorf( + "could not find M3U8 file for playlist %d: %w", + playlistID, err, + ) + } + + parsed, err := parseM3U8(m3uPath) + if err != nil { + return fmt.Errorf( + "could not parse M3U8: %w", err, + ) + } + + targetSet := make( + map[string]struct{}, len(phantomPaths), + ) + + for _, p := range phantomPaths { + targetSet[p] = struct{}{} + } + + updated := removeM3UEntries( + parsed.Entries, targetSet, libraryRoot, + ) + + playlist, err := s.db.Queries.GetPlaylist( + s.db.Ctx, playlistID, + ) + if err != nil { + return fmt.Errorf( + "could not get playlist name: %w", err, + ) + } + + if err := writeM3U8( + dir, playlistID, playlist.Name, updated, + ); err != nil { + return fmt.Errorf( + "could not rewrite M3U8: %w", err, + ) + } + + s.logger.Info( + "Phantom tracks removed", + "playlistId", playlistID, + "removed", len(phantomPaths), + ) + + s.emitEvent(events.PlaylistTracksChanged, playlistID) + + return nil +} + +// searchCandidates finds and scores candidate library tracks +// for a single phantom track. +func (s *Service) searchCandidates( + phantomPath string, + entry m3uEntry, +) []CandidateTrack { + basename := filepath.Base(phantomPath) + seen := make(map[string]struct{}) + + var combined []database.SearchRow + + // 1. Exact basename match via indexed column. + bnRows, err := s.db.Queries.SearchAudioFilesByBasename( + s.db.Ctx, + sqlcgen.SearchAudioFilesByBasenameParams{ + Basename: basename, + Limit: int64(maxCandidates), + }, + ) + if err != nil { + s.logger.Warn( + "Basename search failed", + "basename", basename, + "err", err, + ) + } + + for _, r := range bnRows { + if _, ok := seen[r.FilePath]; ok { + continue + } + + seen[r.FilePath] = struct{}{} + + combined = append(combined, database.SearchRow{ + FilePath: r.FilePath, + LengthMilliseconds: r.LengthMilliseconds, + Title: r.Title, + Artist: r.Artist, + Album: r.Album, + }) + } + + // 2. FTS5 filename-token search for fuzzy basename + // matches (e.g. different extension). + ftsFileRows, err := s.db.SearchFTSByFilename( + basename, maxCandidates, + ) + if err != nil { + s.logger.Warn( + "FTS filename search failed", + "basename", basename, + "err", err, + ) + } + + for _, r := range ftsFileRows { + if _, ok := seen[r.FilePath]; ok { + continue + } + + seen[r.FilePath] = struct{}{} + + combined = append(combined, r) + } + + // 3. FTS5 keyword search from path + display title. + keywords := extractKeywords(phantomPath) + + if entry.DisplayTitle != "" { + titleKeywords := extractKeywords( + entry.DisplayTitle, + ) + keywords = append(keywords, titleKeywords...) + keywords = dedupStrings(keywords) + } + + if len(keywords) > 0 { + kwQuery := strings.Join(keywords, " ") + + kwRows, kwErr := s.db.SearchFTS( + kwQuery, maxCandidates, + ) + if kwErr != nil { + s.logger.Warn( + "FTS keyword search failed", + "keywords", keywords, + "err", kwErr, + ) + } + + for _, r := range kwRows { + if _, ok := seen[r.FilePath]; ok { + continue + } + + seen[r.FilePath] = struct{}{} + + combined = append(combined, r) + } + } + + // Score each candidate. + pp := newPhantomProfile( + phantomPath, entry.DisplayTitle, + entry.DurationSec, + ) + + candidates := make( + []CandidateTrack, 0, len(combined), + ) + + for _, row := range combined { + score := scoreCandidate( + pp, + row.FilePath, + row.Title, + row.Artist, + row.LengthMilliseconds, + ) + + candidates = append(candidates, CandidateTrack{ + FilePath: row.FilePath, + Title: row.Title, + Artist: row.Artist, + Album: row.Album, + Duration: strconv.FormatInt( + row.LengthMilliseconds, 10, + ), + Score: score, + }) + } + + // Sort by score descending. + sortCandidatesByScore(candidates) + + if len(candidates) > maxCandidates { + candidates = candidates[:maxCandidates] + } + + return candidates +} + +// sortCandidatesByScore sorts candidates by score descending. +func sortCandidatesByScore(candidates []CandidateTrack) { + slices.SortFunc( + candidates, + func(a, b CandidateTrack) int { + if a.Score > b.Score { + return -1 + } + + if a.Score < b.Score { + return 1 + } + + return 0 + }, + ) +} diff --git a/frontend/src/components/genre-details/genre-details.ts b/frontend/src/components/genre-details/genre-details.ts index 9426414..e7bab86 100644 --- a/frontend/src/components/genre-details/genre-details.ts +++ b/frontend/src/components/genre-details/genre-details.ts @@ -5,7 +5,9 @@ import { state, } from 'lit/decorators.js'; import { library } from '@go/models'; -import { LibraryController } from '@store/controllers/library-controller'; +import { GetTracksByGenre } from '@go/library/Library'; +import { EventsOn } from '@runtime/runtime'; +import { Events } from '../../events'; import '@awesome.me/webawesome/dist/components/icon/icon.js'; import '@components/track-list/track-list.js'; @@ -20,10 +22,8 @@ export class GenreDetails extends LitElement { @state() private loading = true; - private libraryCtrl = new LibraryController(this); - - /** Tracks the store's cached array reference to detect refreshes. */ - private lastTracksRef: library.Track[] | null = null; + private scanCompleteCleanup: (() => void) | null = + null; static override styles = css` :host { @@ -151,17 +151,19 @@ export class GenreDetails extends LitElement { override connectedCallback() { super.connectedCallback(); this.loadTracks(); + + this.scanCompleteCleanup = EventsOn( + Events.LibraryScanComplete, + () => this.loadTracks(), + ); } - override updated() { - const cached = this.libraryCtrl.cachedTracks; + override disconnectedCallback() { + super.disconnectedCallback(); - if ( - cached !== null && - cached !== this.lastTracksRef - ) { - this.lastTracksRef = cached; - this.loadTracks(); + if (this.scanCompleteCleanup) { + this.scanCompleteCleanup(); + this.scanCompleteCleanup = null; } } @@ -173,14 +175,8 @@ export class GenreDetails extends LitElement { if (!this.genreName) return; try { - const allTracks = - await this.libraryCtrl.getTracks(); - - this.tracks = (allTracks ?? []).filter( - (t) => - (t.Genre ?? []).includes( - this.genreName, - ), + this.tracks = await GetTracksByGenre( + this.genreName, ); } catch (error) { console.error( diff --git a/frontend/src/components/genres-view/genres-view.ts b/frontend/src/components/genres-view/genres-view.ts index 2c505df..9aaec39 100644 --- a/frontend/src/components/genres-view/genres-view.ts +++ b/frontend/src/components/genres-view/genres-view.ts @@ -10,7 +10,12 @@ import type { VisibilityChangedEvent, } from '@lit-labs/virtualizer'; import { grid } from '@lit-labs/virtualizer/layouts/grid.js'; -import { library } from '@go/models'; +import { + GetAllGenresWithCounts, + GetTracksByGenre, +} from '@go/library/Library'; +import { EventsOn } from '@runtime/runtime'; +import { Events } from '../../events'; import { LibraryController } from '@store/controllers/library-controller'; import { SearchController } from '@store/controllers/search-controller'; import { queueStore } from '@store/queue-store'; @@ -61,17 +66,13 @@ export class GenresView private ctxMenu = new ContextMenuController(this); private wheelListenerAttached = false; private lastSearchTerm = ''; - - /** Tracks the store's cached array reference to detect refreshes. */ - private lastTracksRef: library.Track[] | null = + private scanCompleteCleanup: (() => void) | null = null; + private scrollDebounceTimer: ReturnType< typeof setTimeout > | null = null; - /** All tracks from the library (used to derive genres). */ - private allTracks: library.Track[] = []; - @state() private genres: Genre[] = []; @@ -378,12 +379,22 @@ export class GenresView super.connectedCallback(); this.loadCardSize(); this.loadGenres(); + + this.scanCompleteCleanup = EventsOn( + Events.LibraryScanComplete, + () => this.loadGenres(), + ); } override disconnectedCallback() { super.disconnectedCallback(); this.detachWheelListener(); + if (this.scanCompleteCleanup) { + this.scanCompleteCleanup(); + this.scanCompleteCleanup = null; + } + if (this.scrollDebounceTimer !== null) { clearTimeout(this.scrollDebounceTimer); } @@ -401,19 +412,6 @@ export class GenresView this.lastSearchTerm = currentTerm; this.clearSelection(); } - - // Re-fetch when the store delivers fresh - // data after eager refetch on invalidation. - const cached = - this.libraryCtrl.cachedTracks; - - if ( - cached !== null && - cached !== this.lastTracksRef - ) { - this.lastTracksRef = cached; - this.loadGenres(); - } } /* ================================================================ @@ -424,18 +422,18 @@ export class GenresView try { this.loading = true; - const tracks = - await this.libraryCtrl.getTracks(); + const rows = + await GetAllGenresWithCounts(); - this.allTracks = tracks ?? []; - this.genres = - this.extractGenres(this.allTracks); + this.genres = (rows ?? []).map((r) => ({ + name: r.Name, + trackCount: r.TrackCount, + })); } catch (error) { console.error( 'Error loading genres:', error, ); - this.allTracks = []; this.genres = []; } finally { const saved = @@ -451,41 +449,6 @@ export class GenresView this.restoreScrollPosition(); } - /** - * Extract unique genres from all tracks, - * sorted alphabetically by name. - */ - private extractGenres( - tracks: library.Track[], - ): Genre[] { - const counts = new Map(); - - for (const track of tracks) { - const genres = track.Genre ?? []; - - for (const name of genres) { - if (!name) continue; - - counts.set( - name, - (counts.get(name) ?? 0) + 1, - ); - } - } - - const result: Genre[] = []; - - for (const [name, trackCount] of counts) { - result.push({ name, trackCount }); - } - - result.sort((a, b) => - a.name.localeCompare(b.name), - ); - - return result; - } - /* ================================================================ * Scroll position persistence * ================================================================ */ @@ -743,24 +706,28 @@ export class GenresView } /** - * Returns all file paths for every selected - * genre. + * Fetch file paths for a set of genre names by + * querying the backend for each genre. */ - private getSelectedGenreFilePaths(): string[] { - const allPaths: string[] = []; + private async getFilePathsForGenres( + genreNames: Iterable, + ): Promise { const seen = new Set(); + const allPaths: string[] = []; - for (const track of this.allTracks) { - if (seen.has(track.FilePath)) continue; + const promises = Array.from( + genreNames, + (name) => GetTracksByGenre(name), + ); - const genres = track.Genre ?? []; - const match = genres.some((g) => - this.selectedGenres.has(g), - ); + const results = await Promise.all(promises); - if (match) { - allPaths.push(track.FilePath); - seen.add(track.FilePath); + for (const tracks of results) { + for (const track of tracks ?? []) { + if (!seen.has(track.FilePath)) { + seen.add(track.FilePath); + allPaths.push(track.FilePath); + } } } @@ -774,36 +741,23 @@ export class GenresView * genres. Otherwise return paths for the * right-clicked genre only. */ - private getContextMenuGenreFilePaths(): string[] { + private async getContextMenuGenreFilePaths(): Promise< + string[] + > { if ( this.contextMenuGenreName !== null && !this.selectedGenres.has( this.contextMenuGenreName, ) ) { - const paths: string[] = []; - const seen = new Set(); - - for (const track of this.allTracks) { - if (seen.has(track.FilePath)) { - continue; - } - - const genres = track.Genre ?? []; - const match = genres.includes( - this.contextMenuGenreName, - ); - - if (match) { - paths.push(track.FilePath); - seen.add(track.FilePath); - } - } - - return paths; + return this.getFilePathsForGenres([ + this.contextMenuGenreName, + ]); } - return this.getSelectedGenreFilePaths(); + return this.getFilePathsForGenres( + this.selectedGenres, + ); } /** Clear the current genre selection. */ @@ -889,9 +843,11 @@ export class GenresView ); }; - private onContextMenuAction(action: string) { + private async onContextMenuAction( + action: string, + ) { const filePaths = - this.getContextMenuGenreFilePaths(); + await this.getContextMenuGenreFilePaths(); if (filePaths.length === 0) return; @@ -1063,8 +1019,11 @@ export class GenresView class="submenu-item" @mouseenter=${() => { this.ctxMenu.clearSubmenuCloseTimer(); - void this.ctxMenu.showPlaylistSubmenu( - this.getContextMenuGenreFilePaths(), + void this.getContextMenuGenreFilePaths().then( + (paths) => + this.ctxMenu.showPlaylistSubmenu( + paths, + ), ); }} @mouseleave=${this @@ -1074,8 +1033,11 @@ export class GenresView e: Event, ) => { e.stopPropagation(); - void this.ctxMenu.showPlaylistSubmenu( - this.getContextMenuGenreFilePaths(), + void this.getContextMenuGenreFilePaths().then( + (paths) => + this.ctxMenu.showPlaylistSubmenu( + paths, + ), ); }} > diff --git a/frontend/src/components/phantom-resolver/phantom-resolver.ts b/frontend/src/components/phantom-resolver/phantom-resolver.ts new file mode 100644 index 0000000..d621d79 --- /dev/null +++ b/frontend/src/components/phantom-resolver/phantom-resolver.ts @@ -0,0 +1,1302 @@ +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 { + FindPhantomMatches, + GetPhantomCandidates, + SearchLibrary, + ResolvePhantomTracks, + RemovePhantomTracks, +} from '@go/playlist/Service'; +import type { playlist } from '@go/models'; +import { formatMilliseconds } from '@utils/time'; + +const SEARCH_DEBOUNCE_MS = 400; + +/** + * A modal dialog for resolving phantom (unmatched) tracks + * in imported playlists. + */ +@customElement('phantom-resolver') +export class PhantomResolver extends LitElement { + @query('wa-dialog') + private dialog!: HTMLElement & { open: boolean }; + + // ─── State ────────────────────────────────────── + @state() private loading = true; + @state() private autoMatched: playlist.PhantomMatch[] = + []; + @state() private unmatched: string[] = []; + @state() private autoMatchExpanded = false; + @state() private selectedPhantom: string | null = null; + @state() private candidates: playlist.CandidateTrack[] = + []; + @state() private candidatesLoading = false; + @state() private searchQuery = ''; + @state() private searchResults: playlist.CandidateTrack[] = + []; + @state() private searching = false; + + private playlistId = 0; + private phantomTracks: playlist.Track[] = []; + + /** User-confirmed matches: phantomPath -> resolvedFilePath. */ + private confirmedMatches = new Map(); + + /** Auto-match overrides: phantomPath -> null (removed). */ + private autoMatchOverrides = new Map< + string, + string | null + >(); + + private searchTimer: ReturnType | null = + null; + + // ─── Public API ───────────────────────────────── + + show( + playlistId: number, + phantomTracks: playlist.Track[], + ): void { + this.playlistId = playlistId; + this.phantomTracks = phantomTracks; + this.loading = true; + this.autoMatched = []; + this.unmatched = []; + this.autoMatchExpanded = false; + this.selectedPhantom = null; + this.candidates = []; + this.candidatesLoading = false; + this.searchQuery = ''; + this.searchResults = []; + this.searching = false; + this.confirmedMatches.clear(); + this.autoMatchOverrides.clear(); + + this.updateComplete.then(() => { + if (this.dialog) this.dialog.open = true; + void this.runInitialSearch(); + }); + } + + close(): void { + if (this.dialog) this.dialog.open = false; + } + + // ─── Lifecycle ────────────────────────────────── + + override disconnectedCallback(): void { + super.disconnectedCallback(); + + if (this.searchTimer) { + clearTimeout(this.searchTimer); + } + } + + // ─── Data fetching ────────────────────────────── + + private async runInitialSearch(): Promise { + this.loading = true; + + try { + const paths = this.phantomTracks.map( + (t) => t.FilePath, + ); + const result = await FindPhantomMatches( + this.playlistId, + paths, + ); + + this.autoMatched = + result.AutoMatched ?? []; + this.unmatched = result.Unmatched ?? []; + + if (this.unmatched.length > 0) { + this.selectedPhantom = + this.unmatched[0] ?? null; + await this.loadCandidatesForSelected(); + } + } catch (err) { + console.error( + 'Failed to find phantom matches:', + err, + ); + } finally { + this.loading = false; + } + } + + private async loadCandidatesForSelected(): Promise { + if (!this.selectedPhantom) { + this.candidates = []; + + return; + } + + this.candidatesLoading = true; + + try { + this.candidates = + await GetPhantomCandidates( + this.playlistId, + this.selectedPhantom, + ); + } catch (err) { + console.error( + 'Failed to load candidates:', + err, + ); + this.candidates = []; + } finally { + this.candidatesLoading = false; + } + } + + private async runLibrarySearch(): Promise { + const query = this.searchQuery.trim(); + + if (!query) { + this.searchResults = []; + + return; + } + + this.searching = true; + + try { + this.searchResults = + await SearchLibrary(query); + } catch (err) { + console.error( + 'Library search failed:', + err, + ); + this.searchResults = []; + } finally { + this.searching = false; + } + } + + // ─── Event handlers ───────────────────────────── + + private handlePhantomClick(path: string): void { + this.selectedPhantom = path; + this.searchQuery = ''; + this.searchResults = []; + void this.loadCandidatesForSelected(); + } + + private handleCandidateDblClick( + candidate: playlist.CandidateTrack, + ): void { + if (!this.selectedPhantom) return; + + this.confirmedMatches.set( + this.selectedPhantom, + candidate.FilePath, + ); + + // Advance to next unmatched phantom. + const remaining = this.unmatched.filter( + (p) => !this.confirmedMatches.has(p), + ); + + if (remaining.length > 0) { + this.selectedPhantom = + remaining[0] ?? null; + void this.loadCandidatesForSelected(); + } else { + this.selectedPhantom = null; + this.candidates = []; + } + + this.requestUpdate(); + } + + private handleRemoveAutoMatch( + phantomPath: string, + ): void { + this.autoMatchOverrides.set(phantomPath, null); + this.unmatched = [ + ...this.unmatched, + phantomPath, + ]; + + if (!this.selectedPhantom) { + this.selectedPhantom = phantomPath; + void this.loadCandidatesForSelected(); + } + + this.requestUpdate(); + } + + private handleSearchInput = ( + e: InputEvent, + ): void => { + const input = e.target as HTMLInputElement; + this.searchQuery = input.value; + + if (this.searchTimer) { + clearTimeout(this.searchTimer); + } + + this.searchTimer = setTimeout(() => { + void this.runLibrarySearch(); + }, SEARCH_DEBOUNCE_MS); + }; + + private handleSearchKeydown = ( + e: KeyboardEvent, + ): void => { + if (e.key === 'Enter') { + e.preventDefault(); + + if (this.searchTimer) { + clearTimeout(this.searchTimer); + } + + void this.runLibrarySearch(); + } + + e.stopPropagation(); + }; + + private handleRemoveSelected = async (): Promise => { + // Remove all unmatched phantoms that don't have a + // confirmed match. + const toRemove = this.unmatched.filter( + (p) => !this.confirmedMatches.has(p), + ); + + if (toRemove.length === 0) return; + + try { + await RemovePhantomTracks( + this.playlistId, + toRemove, + ); + this.unmatched = this.unmatched.filter( + (p) => !toRemove.includes(p), + ); + this.selectedPhantom = null; + this.candidates = []; + + this.dispatchEvent( + new CustomEvent( + 'phantom-resolved', + { bubbles: true }, + ), + ); + + if ( + this.unmatched.length === 0 && + this.effectiveAutoMatched.length === 0 && + this.confirmedMatches.size === 0 + ) { + this.close(); + } + } catch (err) { + console.error( + 'Failed to remove phantom tracks:', + err, + ); + } + }; + + private handleApplyAndClose = async (): Promise => { + // Collect all matches: auto-matched + confirmed. + const allMatches: Record = {}; + + for (const match of this.effectiveAutoMatched) { + allMatches[match.PhantomPath] = + match.Candidate.FilePath; + } + + for (const [ + phantom, + resolved, + ] of this.confirmedMatches) { + allMatches[phantom] = resolved; + } + + try { + if (Object.keys(allMatches).length > 0) { + await ResolvePhantomTracks( + this.playlistId, + allMatches, + ); + } + } catch (err) { + console.error( + 'Failed to resolve phantom tracks:', + err, + ); + + return; + } + + this.dispatchEvent( + new CustomEvent( + 'phantom-resolved', + { bubbles: true }, + ), + ); + this.close(); + }; + + // ─── Computed ─────────────────────────────────── + + private get effectiveAutoMatched(): playlist.PhantomMatch[] { + return this.autoMatched.filter( + (m) => + !this.autoMatchOverrides.has( + m.PhantomPath, + ), + ); + } + + private get hasChanges(): boolean { + return ( + this.effectiveAutoMatched.length > 0 || + this.confirmedMatches.size > 0 + ); + } + + private get unresolvedCount(): number { + return this.unmatched.filter( + (p) => !this.confirmedMatches.has(p), + ).length; + } + + // ─── Formatting helpers ───────────────────────── + + private formatDuration(ms: string): string { + return formatMilliseconds(ms); + } + + private filenameFromPath(path: string): string { + const parts = path.split('/'); + + return parts[parts.length - 1] ?? path; + } + + private scorePercent(score: number): string { + return `${Math.round(score * 100)}%`; + } + + // ─── Rendering ────────────────────────────────── + + static override styles = [ + css` + wa-dialog { + --width: 860px; + } + + wa-dialog::part(dialog) { + background: var( + --yj-bg-surface, + #212529 + ); + color: var( + --yj-text-primary, + #fff + ); + border: 1px solid + var(--yj-border, #444); + border-radius: 8px; + } + + wa-dialog::part(title) { + font-size: 16px; + font-weight: 600; + color: var( + --yj-text-primary, + #fff + ); + padding: 16px 20px 8px; + } + + wa-dialog::part(header-actions) { + padding: 16px 20px 8px; + } + + wa-dialog::part(close-button__base) { + color: var( + --yj-text-tertiary, + #888 + ); + } + + wa-dialog::part(body) { + padding: 0 20px 20px; + } + + .loading { + text-align: center; + padding: 2em; + color: var( + --yj-text-tertiary, + #888 + ); + } + + /* Auto-match section */ + .auto-match-header { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background: color-mix( + in srgb, + var(--yj-success, #2f9e44) + 15%, + var( + --yj-bg-elevated, + #343a40 + ) + ); + border-radius: 4px; + cursor: pointer; + font-size: 13px; + margin-bottom: 12px; + user-select: none; + } + + .auto-match-header:hover { + background: color-mix( + in srgb, + var(--yj-success, #2f9e44) + 25%, + var( + --yj-bg-elevated, + #343a40 + ) + ); + } + + .auto-match-header wa-icon { + color: var( + --yj-success, + #2f9e44 + ); + font-size: 12px; + transition: transform 0.15s; + } + + .auto-match-header + wa-icon.expanded { + transform: rotate(90deg); + } + + .auto-match-count { + color: var( + --yj-success, + #2f9e44 + ); + font-weight: 600; + } + + .auto-match-list { + margin-bottom: 12px; + } + + .auto-match-pair { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + font-size: 12px; + border-bottom: 1px solid + var( + --yj-border-subtle, + #333 + ); + } + + .auto-match-pair + .phantom-name { + flex: 1; + color: var( + --yj-text-secondary, + #adb5bd + ); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .auto-match-pair .arrow { + color: var( + --yj-text-tertiary, + #888 + ); + flex-shrink: 0; + } + + .auto-match-pair + .match-name { + flex: 1; + color: var( + --yj-text-primary, + #fff + ); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .auto-match-pair .remove-btn { + background: none; + border: none; + color: var( + --yj-text-tertiary, + #888 + ); + cursor: pointer; + padding: 2px; + font-size: 12px; + flex-shrink: 0; + } + + .auto-match-pair + .remove-btn:hover { + color: var( + --yj-error, + #e03131 + ); + } + + /* Two-panel layout */ + .panels { + display: flex; + gap: 1px; + background: var( + --yj-border-subtle, + #333 + ); + border: 1px solid + var( + --yj-border-subtle, + #333 + ); + border-radius: 4px; + overflow: hidden; + min-height: 300px; + max-height: 400px; + } + + .panel-left, + .panel-right { + flex: 1; + background: var( + --yj-bg-elevated, + #343a40 + ); + overflow-y: auto; + display: flex; + flex-direction: column; + } + + .panel-header { + padding: 8px 12px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var( + --yj-text-tertiary, + #888 + ); + border-bottom: 1px solid + var( + --yj-border-subtle, + #333 + ); + flex-shrink: 0; + } + + .panel-body { + flex: 1; + overflow-y: auto; + } + + /* Phantom list items */ + .phantom-item { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + font-size: 12px; + cursor: pointer; + border-bottom: 1px solid + var( + --yj-border-subtle, + #2a2a2a + ); + } + + .phantom-item:hover { + background: rgba( + 255, + 255, + 255, + 0.04 + ); + } + + .phantom-item.selected { + background: rgba( + 255, + 212, + 59, + 0.1 + ); + border-left: 2px solid + var(--yj-accent, #ffd43b); + } + + .phantom-item.matched { + opacity: 0.5; + } + + .phantom-item .check { + color: var( + --yj-success, + #2f9e44 + ); + flex-shrink: 0; + font-size: 12px; + } + + .phantom-item .name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var( + --yj-text-secondary, + #adb5bd + ); + } + + /* Candidate items */ + .candidate-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + font-size: 12px; + cursor: pointer; + border-bottom: 1px solid + var( + --yj-border-subtle, + #2a2a2a + ); + } + + .candidate-item:hover { + background: rgba( + 255, + 255, + 255, + 0.06 + ); + } + + .candidate-info { + flex: 1; + overflow: hidden; + min-width: 0; + } + + .candidate-title { + color: var( + --yj-text-primary, + #fff + ); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .candidate-meta { + font-size: 11px; + color: var( + --yj-text-tertiary, + #888 + ); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin-top: 1px; + } + + .candidate-score { + flex-shrink: 0; + font-size: 10px; + padding: 1px 6px; + border-radius: 3px; + background: rgba( + 255, + 212, + 59, + 0.15 + ); + color: var(--yj-accent, #ffd43b); + } + + .candidate-duration { + flex-shrink: 0; + font-size: 11px; + color: var( + --yj-text-tertiary, + #888 + ); + font-variant-numeric: tabular-nums; + } + + /* Search section */ + .search-section { + border-top: 1px solid + var( + --yj-border-subtle, + #333 + ); + padding: 8px 12px; + flex-shrink: 0; + } + + .search-label { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var( + --yj-text-tertiary, + #888 + ); + margin-bottom: 4px; + } + + .search-input { + width: 100%; + box-sizing: border-box; + padding: 6px 8px; + background: var( + --yj-bg-surface, + #212529 + ); + border: 1px solid + var( + --yj-border-subtle, + #555 + ); + border-radius: 4px; + color: var( + --yj-text-primary, + #fff + ); + font-size: 12px; + font-family: inherit; + outline: none; + } + + .search-input:focus { + border-color: var( + --yj-accent, + #ffd43b + ); + } + + .search-input::placeholder { + color: var( + --yj-text-tertiary, + #888 + ); + } + + .empty-message { + text-align: center; + padding: 2em 1em; + color: var( + --yj-text-tertiary, + #888 + ); + font-size: 12px; + } + + /* Footer buttons */ + .footer { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 16px; + } + + .btn { + background: none; + border: 1px solid + var( + --yj-border-subtle, + #555 + ); + border-radius: 4px; + color: var( + --yj-text-primary, + #fff + ); + padding: 6px 16px; + font-size: 13px; + cursor: pointer; + font-family: inherit; + } + + .btn:hover { + border-color: var( + --yj-accent, + #ffd43b + ); + color: var(--yj-accent, #ffd43b); + } + + .btn-danger { + color: var( + --yj-text-secondary, + #adb5bd + ); + } + + .btn-danger:hover { + border-color: var( + --yj-error, + #e03131 + ); + color: var( + --yj-error, + #e03131 + ); + } + + .btn-primary { + background: var( + --yj-accent, + #ffd43b + ); + color: #000; + border-color: var( + --yj-accent, + #ffd43b + ); + font-weight: 600; + } + + .btn-primary:hover { + background: color-mix( + in srgb, + var(--yj-accent, #ffd43b) + 85%, + #000 + ); + color: #000; + } + + .btn:disabled { + opacity: 0.4; + cursor: not-allowed; + } + + .dbl-click-hint { + font-size: 10px; + color: var( + --yj-text-tertiary, + #666 + ); + text-align: center; + padding: 4px; + } + `, + ]; + + override render() { + return html` + + ${this.loading + ? html`
+ Searching for + matches... +
` + : this.renderContent()} +
+ `; + } + + private renderContent() { + return html` + ${this.renderAutoMatchSection()} + ${this.unmatched.length > 0 || + this.confirmedMatches.size > 0 + ? this.renderPanels() + : nothing} + ${this.renderFooter()} + `; + } + + private renderAutoMatchSection() { + const matches = this.effectiveAutoMatched; + + if (matches.length === 0) return nothing; + + return html` +
{ + this.autoMatchExpanded = + !this.autoMatchExpanded; + }} + > + + + ${matches.length} + track${matches.length !== 1 + ? 's' + : ''} + auto-matched + + + (click to review) + +
+ ${this.autoMatchExpanded + ? html`
+ ${matches.map( + (m) => html` +
+ + ${m.PhantomTitle || + this.filenameFromPath( + m.PhantomPath, + )} + + + + ${m.Candidate + .Title || + this.filenameFromPath( + m.Candidate + .FilePath, + )} + ${m.Candidate + .Artist + ? html` + — + ${m + .Candidate + .Artist} + ` + : nothing} + + +
+ `, + )} +
` + : nothing} + `; + } + + private renderPanels() { + return html` +
+
+
+ Unmatched + (${this.unresolvedCount}) +
+
+ ${this.unmatched.map( + (path) => { + const isSelected = + this + .selectedPhantom === + path; + const isMatched = + this.confirmedMatches.has( + path, + ); + const track = + this.phantomTracks.find( + (t) => + t.FilePath === + path, + ); + const label = + track?.Title || + this.filenameFromPath( + path, + ); + + return html` +
+ this.handlePhantomClick( + path, + )} + title=${path} + > + ${isMatched + ? html`` + : nothing} + + ${label} + +
+ `; + }, + )} +
+
+
+ ${this.selectedPhantom + ? this.renderRightPanel() + : html`
+ Select a phantom + track to see + candidates. +
`} +
+
+ `; + } + + private renderRightPanel() { + const label = + this.phantomTracks.find( + (t) => + t.FilePath === + this.selectedPhantom, + )?.Title || + this.filenameFromPath( + this.selectedPhantom ?? '', + ); + + return html` +
+ Candidates for + “${label}” +
+
+ ${this.candidatesLoading + ? html`
+ Searching... +
` + : this.candidates.length > 0 + ? html` +
+ Double-click a + result to match +
+ ${this.candidates.map( + (c) => + this.renderCandidateItem( + c, + ), + )} + ` + : html`
+ No smart matches + found. Try + searching below. +
`} + ${this.searchResults.length > 0 + ? html` +
+ Library search + results +
+ ${this.searchResults.map( + (c) => + this.renderCandidateItem( + c, + ), + )} + ` + : nothing} + ${this.searching + ? html`
+ Searching + library... +
` + : nothing} +
+
+
+ Search Library +
+ +
+ `; + } + + private renderCandidateItem( + c: playlist.CandidateTrack, + ) { + const title = + c.Title || + this.filenameFromPath(c.FilePath); + const meta = [c.Artist, c.Album] + .filter(Boolean) + .join(' \u2014 '); + + return html` +
+ this.handleCandidateDblClick( + c, + )} + title=${c.FilePath} + > +
+
+ ${title} +
+ ${meta + ? html`
+ ${meta} +
` + : nothing} +
+ ${c.Score > 0 + ? html` + ${this.scorePercent( + c.Score, + )} + ` + : nothing} + + ${this.formatDuration( + c.Duration, + )} + +
+ `; + } + + private renderFooter() { + return html` + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'phantom-resolver': PhantomResolver; + } +} diff --git a/frontend/src/components/playlist-view/playlist-view.ts b/frontend/src/components/playlist-view/playlist-view.ts index 8179530..df8c802 100644 --- a/frontend/src/components/playlist-view/playlist-view.ts +++ b/frontend/src/components/playlist-view/playlist-view.ts @@ -13,6 +13,7 @@ import { DeletePlaylist, RenamePlaylist, ImportPlaylist, + RemovePhantomTracks, } from '@go/playlist/Service'; import { PlaylistFilePicker } from '@go/frontendutil/FrontendUtil'; import type { playlist } from '@go/models'; @@ -44,6 +45,8 @@ import { contextMenuStyles } from '@utils/context-menu-controller.js'; import '@components/track-details/track-details.js'; import type { TrackDetails } from '@components/track-details/track-details.js'; import type { CoverArtUrls } from '@components/track-details/track-details.js'; +import '@components/phantom-resolver/phantom-resolver.js'; +import type { PhantomResolver } from '@components/phantom-resolver/phantom-resolver.js'; const SCROLL_DEBOUNCE_MS = 100; @@ -191,6 +194,9 @@ export class PlaylistView /** True when dragging over the "New Playlist" button. */ @state() private dragOverNewButton = false; + /** Error message from the last failed import, auto-clears. */ + @state() private importError = ''; + /** * File paths from a drop that landed outside any playlist. * When non-empty the create form is in "create-and-add" mode. @@ -211,6 +217,9 @@ export class PlaylistView @query('track-details') private trackDetailsDialog!: TrackDetails; + @query('phantom-resolver') + private phantomResolver!: PhantomResolver; + private closePlaylistCtxMenuHandler = () => this.closePlaylistContextMenu(); @@ -579,23 +588,83 @@ export class PlaylistView } .track-item.phantom { - opacity: 0.45; - cursor: not-allowed; + cursor: pointer; } .track-item.phantom:hover { - background-color: transparent; + background-color: var( + --yj-hover-overlay, + rgba(255, 255, 255, 0.05) + ); } - .phantom-badge { - display: inline-block; - font-size: 10px; + .track-item.phantom.selected { + background-color: var( + --yj-selection-bg, + rgba(100, 160, 255, 0.15) + ); + } + + .phantom-row { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + width: 100%; + } + + .phantom-caution { + flex-shrink: 0; + font-size: 14px; color: var(--yj-warning, #e67700); - background: rgba(230, 119, 0, 0.15); - padding: 1px 6px; + } + + .phantom-path { + flex: 1; + min-width: 0; + font-size: 12px; + color: var(--yj-text-tertiary, #888); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .phantom-actions { + display: flex; + align-items: center; + gap: 2px; + flex-shrink: 0; + } + + .phantom-icon-btn { + display: flex; + align-items: center; + justify-content: center; + background: none; + border: none; + color: var(--yj-text-tertiary, #888); + cursor: pointer; + padding: 4px; border-radius: 3px; - margin-left: 8px; - vertical-align: middle; + font-size: 13px; + } + + .phantom-icon-btn:hover { + color: var( + --yj-text-primary, + #fff + ); + background: rgba( + 255, + 255, + 255, + 0.08 + ); + } + + .phantom-icon-btn.phantom-icon-remove:hover { + color: var(--yj-error, #e03131); + background: rgba(224, 49, 49, 0.12); } .track-item:last-child { @@ -747,6 +816,23 @@ export class PlaylistView border-color: var(--yj-accent, #ffd43b); color: var(--yj-accent, #ffd43b); } + + .import-error { + padding: 0.5em 0.75em; + margin: 0.5em 16px 0; + font-size: 0.8em; + color: var(--yj-error, #e03131); + background: color-mix( + in srgb, + var(--yj-error, #e03131) 10%, + var(--yj-bg-elevated, #343a40) + ); + border-radius: 4px; + border-left: 3px solid + var(--yj-error, #e03131); + } + + `]; override connectedCallback() { @@ -1045,12 +1131,60 @@ export class PlaylistView case 'track-details': this.openTrackDetails(filePaths[0]!); break; + case 'phantom-locate': + if (this.activePlaylistIndex >= 0) { + this.openPhantomResolver( + this.activePlaylistIndex, + ); + } + + break; + case 'phantom-remove': + void this.removeSelectedPhantoms(); + break; } this.selection.clear(); this.ctxMenu.close(); } + private async removeSelectedPhantoms(): Promise { + if (this.activePlaylistIndex < 0) return; + + const entry = + this.entries[ + this.activePlaylistIndex + ]; + + if (!entry) return; + + const selectedIndices = + this.selection.getSelectedIndices(); + const phantomPaths = selectedIndices + .map((i) => entry.tracks[i]) + .filter( + (t): t is playlist.Track => + t !== undefined && + t.Phantom, + ) + .map((t) => t.FilePath); + + if (phantomPaths.length === 0) return; + + try { + await RemovePhantomTracks( + entry.summary.ID, + phantomPaths, + ); + await this.refreshPlaylists(); + } catch (err) { + console.error( + 'Failed to remove phantom tracks:', + err, + ); + } + } + private openTrackDetails(filePath: string) { const tracks = libraryStore.getCachedTracks(); @@ -1574,6 +1708,143 @@ export class PlaylistView } } + /** + * Check whether all currently selected tracks are phantoms. + * Returns false if nothing is selected or the active playlist + * index is unset. + */ + private isPhantomSelection(): boolean { + if (this.activePlaylistIndex < 0) return false; + + const entry = + this.entries[this.activePlaylistIndex]; + + if (!entry) return false; + + const indices = + this.selection.getSelectedIndices(); + + if (indices.length === 0) return false; + + return indices.every((i) => { + const t = entry.tracks[i]; + + return t !== undefined && t.Phantom; + }); + } + + // ================================================================= + // Phantom track interactions + // ================================================================= + + private handlePhantomClick( + e: MouseEvent, + trackIndex: number, + playlistIndex: number, + ): void { + this.ensureSelectionScope(playlistIndex); + this.selection.handleItemClick( + e, + String(trackIndex), + trackIndex, + ); + } + + private handlePhantomContextMenu( + e: MouseEvent, + trackIndex: number, + playlistIndex: number, + ): void { + e.preventDefault(); + e.stopPropagation(); + this.ensureSelectionScope(playlistIndex); + this.selection.handleContextMenu( + String(trackIndex), + ); + this.ctxMenu.openAt(e.clientX, e.clientY); + } + + private openPhantomResolver( + playlistIndex: number, + trackIndex?: number, + ): void { + const entry = + this.entries[playlistIndex]; + + if (!entry) return; + + // Collect selected phantom tracks, or just the + // one that was clicked. + let phantoms: playlist.Track[]; + + if ( + this.activePlaylistIndex === + playlistIndex + ) { + const selectedIndices = + this.selection.getSelectedIndices(); + phantoms = selectedIndices + .map( + (i) => entry.tracks[i], + ) + .filter( + (t): t is playlist.Track => + t !== undefined && + t.Phantom, + ); + } else { + phantoms = []; + } + + // Fall back to the clicked track. + if ( + phantoms.length === 0 && + trackIndex !== undefined + ) { + const track = + entry.tracks[trackIndex]; + + if (track?.Phantom) { + phantoms = [track]; + } + } + + if (phantoms.length === 0) return; + + this.phantomResolver.show( + entry.summary.ID, + phantoms, + ); + } + + private async removePhantomTrack( + playlistIndex: number, + trackIndex: number, + ): Promise { + const entry = + this.entries[playlistIndex]; + + if (!entry) return; + + const track = + entry.tracks[trackIndex]; + + if (!track?.Phantom) return; + + try { + await RemovePhantomTracks( + entry.summary.ID, + [track.FilePath], + ); + await this.refreshPlaylists(); + } catch (err) { + console.error( + 'Failed to remove phantom track:', + err, + ); + } + } + // ================================================================= // Import playlist // ================================================================= @@ -1585,13 +1856,20 @@ export class PlaylistView if (!filePath) return; + this.importError = ''; await ImportPlaylist(filePath); - await this.refreshPlaylists(); } catch (err) { console.error( 'Failed to import playlist:', err, ); + this.importError = + err instanceof Error + ? err.message + : String(err); + setTimeout(() => { + this.importError = ''; + }, 6000); } }; @@ -1707,6 +1985,12 @@ export class PlaylistView + ${this.importError + ? html`
+ ${this.importError} +
` + : nothing} + ${this.searchCtrl.term && this.filteredEntries.length > 0 ? html`
@@ -1735,111 +2019,144 @@ export class PlaylistView .contextMenuOpen} > ${this.ctxMenu.contextMenuOpen - ? html` -
- - this.onContextMenuAction( - 'play', - )} - @mouseenter=${() => - this.ctxMenu.closePlaylistSubmenu()} - > - - Play - - - this.onContextMenuAction( - 'add-to-queue', - )} - @mouseenter=${() => - this.ctxMenu.closePlaylistSubmenu()} - > - - Add to Queue - - - this.onContextMenuAction( - 'play-next', - )} - @mouseenter=${() => - this.ctxMenu.closePlaylistSubmenu()} - > - - Play Next - - - this.onContextMenuAction( - 'remove', - )} - @mouseenter=${() => - this.ctxMenu.closePlaylistSubmenu()} - > - - Remove from Playlist - - { - this.ctxMenu.clearSubmenuCloseTimer(); - void this.ctxMenu.showPlaylistSubmenu(this.getSelectedFilePaths()); - }} - @mouseleave=${this - .ctxMenu - .scheduleSubmenuClose} - @click=${(e: Event) => { - e.stopPropagation(); - void this.ctxMenu.showPlaylistSubmenu(this.getSelectedFilePaths()); - }} - > - - Add to Playlist - - ▶ - - - ${this.selection - .selectionCount === 1 - ? html` - - this.onContextMenuAction( - 'track-details', - )} - @mouseenter=${() => - this.ctxMenu.closePlaylistSubmenu()} - > - - Track - Details - - ` - : nothing} -
- ` + ? this.isPhantomSelection() + ? html` +
+ + this.onContextMenuAction( + 'phantom-locate', + )} + > + + Locate in + Library + + + this.onContextMenuAction( + 'phantom-remove', + )} + > + + Remove from + Playlist + +
+ ` + : html` +
+ + this.onContextMenuAction( + 'play', + )} + @mouseenter=${() => + this.ctxMenu.closePlaylistSubmenu()} + > + + Play + + + this.onContextMenuAction( + 'add-to-queue', + )} + @mouseenter=${() => + this.ctxMenu.closePlaylistSubmenu()} + > + + Add to Queue + + + this.onContextMenuAction( + 'play-next', + )} + @mouseenter=${() => + this.ctxMenu.closePlaylistSubmenu()} + > + + Play Next + + + this.onContextMenuAction( + 'remove', + )} + @mouseenter=${() => + this.ctxMenu.closePlaylistSubmenu()} + > + + Remove from + Playlist + + { + this.ctxMenu.clearSubmenuCloseTimer(); + void this.ctxMenu.showPlaylistSubmenu(this.getSelectedFilePaths()); + }} + @mouseleave=${this + .ctxMenu + .scheduleSubmenuClose} + @click=${(e: Event) => { + e.stopPropagation(); + void this.ctxMenu.showPlaylistSubmenu(this.getSelectedFilePaths()); + }} + > + + Add to Playlist + + ▶ + + + ${this.selection + .selectionCount === + 1 + ? html` + + this.onContextMenuAction( + 'track-details', + )} + @mouseenter=${() => + this.ctxMenu.closePlaylistSubmenu()} + > + + Track + Details + + ` + : nothing} +
+ ` : nothing} @@ -1917,6 +2234,10 @@ export class PlaylistView + + this.refreshPlaylists()} + > `; } @@ -2153,7 +2474,6 @@ export class PlaylistView track, ); const selected = - !isPhantom && this.activePlaylistIndex === playlistIndex && this.selection.isSelected( @@ -2180,7 +2500,14 @@ export class PlaylistView ? 'false' : 'true'} @click=${isPhantom - ? nothing + ? ( + e: MouseEvent, + ) => + this.handlePhantomClick( + e, + trackIndex, + playlistIndex, + ) : ( e: MouseEvent, ) => @@ -2199,7 +2526,14 @@ export class PlaylistView playlistIndex, )} @contextmenu=${isPhantom - ? nothing + ? ( + e: MouseEvent, + ) => + this.handlePhantomContextMenu( + e, + trackIndex, + playlistIndex, + ) : ( e: MouseEvent, ) => @@ -2224,20 +2558,67 @@ export class PlaylistView : this .onTrackDragEnd} > - ${isPhantom - ? html`File not - found` - : nothing} + ? html`
+ + + ${track.FilePath} + +
+ + +
+
` + : html``}
`; }, diff --git a/frontend/src/components/track-details/track-details.ts b/frontend/src/components/track-details/track-details.ts index 2a0f56b..85cb705 100644 --- a/frontend/src/components/track-details/track-details.ts +++ b/frontend/src/components/track-details/track-details.ts @@ -48,10 +48,7 @@ export class TrackDetails extends LitElement { @state() private editValues: Record = {}; @query('wa-dialog') - private dialog!: HTMLElement & { - show: () => void; - hide: () => void; - }; + private dialog!: HTMLElement & { open: boolean }; // ================================================================= // PUBLIC API @@ -68,13 +65,13 @@ export class TrackDetails extends LitElement { this.editValues = {}; this.updateComplete.then(() => { - this.dialog?.show(); + if (this.dialog) this.dialog.open = true; }); } /** Close the dialog. */ close(): void { - this.dialog?.hide(); + if (this.dialog) this.dialog.open = false; this.editing = false; this.editValues = {}; } diff --git a/frontend/wailsjs/go/library/Library.d.ts b/frontend/wailsjs/go/library/Library.d.ts index 2b1ef4f..4f32e4d 100755 --- a/frontend/wailsjs/go/library/Library.d.ts +++ b/frontend/wailsjs/go/library/Library.d.ts @@ -13,10 +13,16 @@ export function GetAllAlbums():Promise>; export function GetAllArtists():Promise>; +export function GetAllGenresWithCounts():Promise>; + export function GetAllTracks():Promise>; +export function GetTracksByGenre(arg1:string):Promise>; + export function Scan():Promise; +export function SearchTracks(arg1:string):Promise>; + export function SetContext(arg1:context.Context):Promise; export function SetRescanHooks(arg1:library.RescanHooks):Promise; diff --git a/frontend/wailsjs/go/library/Library.js b/frontend/wailsjs/go/library/Library.js index 4c1e191..be22bf5 100755 --- a/frontend/wailsjs/go/library/Library.js +++ b/frontend/wailsjs/go/library/Library.js @@ -22,14 +22,26 @@ export function GetAllArtists() { return window['go']['library']['Library']['GetAllArtists'](); } +export function GetAllGenresWithCounts() { + return window['go']['library']['Library']['GetAllGenresWithCounts'](); +} + export function GetAllTracks() { return window['go']['library']['Library']['GetAllTracks'](); } +export function GetTracksByGenre(arg1) { + return window['go']['library']['Library']['GetTracksByGenre'](arg1); +} + export function Scan() { return window['go']['library']['Library']['Scan'](); } +export function SearchTracks(arg1) { + return window['go']['library']['Library']['SearchTracks'](arg1); +} + export function SetContext(arg1) { return window['go']['library']['Library']['SetContext'](arg1); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 1048cf9..40523d4 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -40,6 +40,20 @@ export namespace library { this.Name = source["Name"]; } } + export class GenreWithCount { + Name: string; + TrackCount: number; + + static createFrom(source: any = {}) { + return new GenreWithCount(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.Name = source["Name"]; + this.TrackCount = source["TrackCount"]; + } + } export class RescanHooks { @@ -159,6 +173,94 @@ export namespace library { export namespace playlist { + export class CandidateTrack { + FilePath: string; + Title: string; + Artist: string; + Album: string; + Duration: string; + Score: number; + + static createFrom(source: any = {}) { + return new CandidateTrack(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.FilePath = source["FilePath"]; + this.Title = source["Title"]; + this.Artist = source["Artist"]; + this.Album = source["Album"]; + this.Duration = source["Duration"]; + this.Score = source["Score"]; + } + } + export class PhantomMatch { + PhantomPath: string; + PhantomTitle: string; + Candidate: CandidateTrack; + + static createFrom(source: any = {}) { + return new PhantomMatch(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.PhantomPath = source["PhantomPath"]; + this.PhantomTitle = source["PhantomTitle"]; + this.Candidate = this.convertValues(source["Candidate"], CandidateTrack); + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } + export class PhantomSearchResult { + AutoMatched: PhantomMatch[]; + Unmatched: string[]; + + static createFrom(source: any = {}) { + return new PhantomSearchResult(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.AutoMatched = this.convertValues(source["AutoMatched"], PhantomMatch); + this.Unmatched = source["Unmatched"]; + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } export class Summary { ID: number; Name: string; diff --git a/frontend/wailsjs/go/playlist/Service.d.ts b/frontend/wailsjs/go/playlist/Service.d.ts index f432d23..3ce94c9 100755 --- a/frontend/wailsjs/go/playlist/Service.d.ts +++ b/frontend/wailsjs/go/playlist/Service.d.ts @@ -11,18 +11,28 @@ export function CreatePlaylistWithTracks(arg1:string,arg2:Array):Promise export function DeletePlaylist(arg1:number):Promise; +export function FindPhantomMatches(arg1:number,arg2:Array):Promise; + export function GetAllPlaylists():Promise>; export function GetAllPlaylistsWithTracks():Promise>; +export function GetPhantomCandidates(arg1:number,arg2:string):Promise>; + export function GetPlaylistTracks(arg1:number):Promise>; export function ImportPlaylist(arg1:string):Promise; +export function RemovePhantomTracks(arg1:number,arg2:Array):Promise; + export function RemoveTracksFromPlaylist(arg1:number,arg2:Array):Promise; export function RenamePlaylist(arg1:number,arg2:string):Promise; +export function ResolvePhantomTracks(arg1:number,arg2:Record):Promise; + export function RestoreAllPlaylists():Promise; +export function SearchLibrary(arg1:string):Promise>; + export function SetContext(arg1:context.Context):Promise; diff --git a/frontend/wailsjs/go/playlist/Service.js b/frontend/wailsjs/go/playlist/Service.js index b5dc496..7d0c805 100755 --- a/frontend/wailsjs/go/playlist/Service.js +++ b/frontend/wailsjs/go/playlist/Service.js @@ -18,6 +18,10 @@ export function DeletePlaylist(arg1) { return window['go']['playlist']['Service']['DeletePlaylist'](arg1); } +export function FindPhantomMatches(arg1, arg2) { + return window['go']['playlist']['Service']['FindPhantomMatches'](arg1, arg2); +} + export function GetAllPlaylists() { return window['go']['playlist']['Service']['GetAllPlaylists'](); } @@ -26,6 +30,10 @@ export function GetAllPlaylistsWithTracks() { return window['go']['playlist']['Service']['GetAllPlaylistsWithTracks'](); } +export function GetPhantomCandidates(arg1, arg2) { + return window['go']['playlist']['Service']['GetPhantomCandidates'](arg1, arg2); +} + export function GetPlaylistTracks(arg1) { return window['go']['playlist']['Service']['GetPlaylistTracks'](arg1); } @@ -34,6 +42,10 @@ export function ImportPlaylist(arg1) { return window['go']['playlist']['Service']['ImportPlaylist'](arg1); } +export function RemovePhantomTracks(arg1, arg2) { + return window['go']['playlist']['Service']['RemovePhantomTracks'](arg1, arg2); +} + export function RemoveTracksFromPlaylist(arg1, arg2) { return window['go']['playlist']['Service']['RemoveTracksFromPlaylist'](arg1, arg2); } @@ -42,10 +54,18 @@ export function RenamePlaylist(arg1, arg2) { return window['go']['playlist']['Service']['RenamePlaylist'](arg1, arg2); } +export function ResolvePhantomTracks(arg1, arg2) { + return window['go']['playlist']['Service']['ResolvePhantomTracks'](arg1, arg2); +} + export function RestoreAllPlaylists() { return window['go']['playlist']['Service']['RestoreAllPlaylists'](); } +export function SearchLibrary(arg1) { + return window['go']['playlist']['Service']['SearchLibrary'](arg1); +} + export function SetContext(arg1) { return window['go']['playlist']['Service']['SetContext'](arg1); } diff --git a/frontend/wailsjs/runtime/package.json b/frontend/wailsjs/runtime/package.json old mode 100644 new mode 100755 diff --git a/frontend/wailsjs/runtime/runtime.d.ts b/frontend/wailsjs/runtime/runtime.d.ts old mode 100644 new mode 100755 diff --git a/frontend/wailsjs/runtime/runtime.js b/frontend/wailsjs/runtime/runtime.js old mode 100644 new mode 100755