playlist phantom track matching added, updated search queries for efficiency

This commit is contained in:
2026-02-24 21:23:25 -05:00
parent ff7649600b
commit e192d4625e
31 changed files with 5222 additions and 316 deletions
+352
View File
@@ -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<library.Track[]>` — 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 |
+109
View File
@@ -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
}
+410
View File
@@ -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
}
+21 -2
View File
@@ -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;
+47
View File
@@ -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;
@@ -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)
);
@@ -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'
);
@@ -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
+137
View File
@@ -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
+8
View File
@@ -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
}
+93 -4
View File
@@ -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,
+183 -22
View File
@@ -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
}
+9
View File
@@ -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,
+81 -9
View File
@@ -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)
+275 -7
View File
@@ -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)
}
}
+379
View File
@@ -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)
}
+411
View File
@@ -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
}
+571 -4
View File
@@ -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
},
)
}
@@ -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(
@@ -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<string, number>();
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<string>,
): Promise<string[]> {
const seen = new Set<string>();
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<string>();
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,
),
);
}}
>
File diff suppressed because it is too large Load Diff
@@ -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<void> {
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<void> {
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
</div>
</div>
${this.importError
? html`<div class="import-error">
${this.importError}
</div>`
: nothing}
${this.searchCtrl.term &&
this.filteredEntries.length > 0
? html`<div class="search-indicator">
@@ -1735,111 +2019,144 @@ export class PlaylistView
.contextMenuOpen}
>
${this.ctxMenu.contextMenuOpen
? html`
<div class="context-menu-panel">
<wa-dropdown-item
@click=${() =>
this.onContextMenuAction(
'play',
)}
@mouseenter=${() =>
this.ctxMenu.closePlaylistSubmenu()}
>
<wa-icon
slot="icon"
name="play"
></wa-icon>
Play
</wa-dropdown-item>
<wa-dropdown-item
@click=${() =>
this.onContextMenuAction(
'add-to-queue',
)}
@mouseenter=${() =>
this.ctxMenu.closePlaylistSubmenu()}
>
<wa-icon
slot="icon"
name="plus"
></wa-icon>
Add to Queue
</wa-dropdown-item>
<wa-dropdown-item
@click=${() =>
this.onContextMenuAction(
'play-next',
)}
@mouseenter=${() =>
this.ctxMenu.closePlaylistSubmenu()}
>
<wa-icon
slot="icon"
name="forward-step"
></wa-icon>
Play Next
</wa-dropdown-item>
<wa-dropdown-item
@click=${() =>
this.onContextMenuAction(
'remove',
)}
@mouseenter=${() =>
this.ctxMenu.closePlaylistSubmenu()}
>
<wa-icon
slot="icon"
name="trash"
></wa-icon>
Remove from Playlist
</wa-dropdown-item>
<wa-dropdown-item
class="submenu-item"
@mouseenter=${() => {
this.ctxMenu.clearSubmenuCloseTimer();
void this.ctxMenu.showPlaylistSubmenu(this.getSelectedFilePaths());
}}
@mouseleave=${this
.ctxMenu
.scheduleSubmenuClose}
@click=${(e: Event) => {
e.stopPropagation();
void this.ctxMenu.showPlaylistSubmenu(this.getSelectedFilePaths());
}}
>
<wa-icon
slot="icon"
name="plus"
></wa-icon>
Add to Playlist
<span
class="submenu-arrow"
>
&#9654;
</span>
</wa-dropdown-item>
${this.selection
.selectionCount === 1
? html`
<wa-dropdown-item
@click=${() =>
this.onContextMenuAction(
'track-details',
)}
@mouseenter=${() =>
this.ctxMenu.closePlaylistSubmenu()}
>
<wa-icon
slot="icon"
name="circle-info"
></wa-icon>
Track
Details
</wa-dropdown-item>
`
: nothing}
</div>
`
? this.isPhantomSelection()
? html`
<div class="context-menu-panel">
<wa-dropdown-item
@click=${() =>
this.onContextMenuAction(
'phantom-locate',
)}
>
<wa-icon
slot="icon"
name="magnifying-glass"
></wa-icon>
Locate in
Library
</wa-dropdown-item>
<wa-dropdown-item
@click=${() =>
this.onContextMenuAction(
'phantom-remove',
)}
>
<wa-icon
slot="icon"
name="trash"
></wa-icon>
Remove from
Playlist
</wa-dropdown-item>
</div>
`
: html`
<div class="context-menu-panel">
<wa-dropdown-item
@click=${() =>
this.onContextMenuAction(
'play',
)}
@mouseenter=${() =>
this.ctxMenu.closePlaylistSubmenu()}
>
<wa-icon
slot="icon"
name="play"
></wa-icon>
Play
</wa-dropdown-item>
<wa-dropdown-item
@click=${() =>
this.onContextMenuAction(
'add-to-queue',
)}
@mouseenter=${() =>
this.ctxMenu.closePlaylistSubmenu()}
>
<wa-icon
slot="icon"
name="plus"
></wa-icon>
Add to Queue
</wa-dropdown-item>
<wa-dropdown-item
@click=${() =>
this.onContextMenuAction(
'play-next',
)}
@mouseenter=${() =>
this.ctxMenu.closePlaylistSubmenu()}
>
<wa-icon
slot="icon"
name="forward-step"
></wa-icon>
Play Next
</wa-dropdown-item>
<wa-dropdown-item
@click=${() =>
this.onContextMenuAction(
'remove',
)}
@mouseenter=${() =>
this.ctxMenu.closePlaylistSubmenu()}
>
<wa-icon
slot="icon"
name="trash"
></wa-icon>
Remove from
Playlist
</wa-dropdown-item>
<wa-dropdown-item
class="submenu-item"
@mouseenter=${() => {
this.ctxMenu.clearSubmenuCloseTimer();
void this.ctxMenu.showPlaylistSubmenu(this.getSelectedFilePaths());
}}
@mouseleave=${this
.ctxMenu
.scheduleSubmenuClose}
@click=${(e: Event) => {
e.stopPropagation();
void this.ctxMenu.showPlaylistSubmenu(this.getSelectedFilePaths());
}}
>
<wa-icon
slot="icon"
name="plus"
></wa-icon>
Add to Playlist
<span
class="submenu-arrow"
>
&#9654;
</span>
</wa-dropdown-item>
${this.selection
.selectionCount ===
1
? html`
<wa-dropdown-item
@click=${() =>
this.onContextMenuAction(
'track-details',
)}
@mouseenter=${() =>
this.ctxMenu.closePlaylistSubmenu()}
>
<wa-icon
slot="icon"
name="circle-info"
></wa-icon>
Track
Details
</wa-dropdown-item>
`
: nothing}
</div>
`
: nothing}
</wa-popup>
@@ -1917,6 +2234,10 @@ export class PlaylistView
</wa-popup>
<track-details></track-details>
<phantom-resolver
@phantom-resolved=${() =>
this.refreshPlaylists()}
></phantom-resolver>
`;
}
@@ -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}
>
<track-info
.trackTitle=${track.Title ||
track.FilePath}
.artist=${track.Artist}
.duration=${track.Duration}
.filePath=${track.FilePath}
></track-info>
${isPhantom
? html`<span
class="phantom-badge"
>File not
found</span
>`
: nothing}
? html`<div
class="phantom-row"
>
<wa-icon
class="phantom-caution"
name="triangle-exclamation"
title="File not found"
></wa-icon>
<span
class="phantom-path"
title=${track.FilePath}
>
${track.FilePath}
</span>
<div
class="phantom-actions"
>
<button
class="phantom-icon-btn"
title="Locate in library"
@click=${(
e: Event,
) => {
e.stopPropagation();
this.openPhantomResolver(
playlistIndex,
trackIndex,
);
}}
>
<wa-icon
name="magnifying-glass"
></wa-icon>
</button>
<button
class="phantom-icon-btn phantom-icon-remove"
title="Remove from playlist"
@click=${(
e: Event,
) => {
e.stopPropagation();
void this.removePhantomTrack(
playlistIndex,
trackIndex,
);
}}
>
<wa-icon
name="xmark"
></wa-icon>
</button>
</div>
</div>`
: html`<track-info
.trackTitle=${track.Title ||
track.FilePath}
.artist=${track.Artist}
.duration=${track.Duration}
.filePath=${track.FilePath}
></track-info>`}
</div>
`;
},
@@ -48,10 +48,7 @@ export class TrackDetails extends LitElement {
@state() private editValues: Record<string, string> = {};
@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 = {};
}
+6
View File
@@ -13,10 +13,16 @@ export function GetAllAlbums():Promise<Array<library.Album>>;
export function GetAllArtists():Promise<Array<library.Artist>>;
export function GetAllGenresWithCounts():Promise<Array<library.GenreWithCount>>;
export function GetAllTracks():Promise<Array<library.Track>>;
export function GetTracksByGenre(arg1:string):Promise<Array<library.Track>>;
export function Scan():Promise<library.ScanMetrics>;
export function SearchTracks(arg1:string):Promise<Array<library.Track>>;
export function SetContext(arg1:context.Context):Promise<void>;
export function SetRescanHooks(arg1:library.RescanHooks):Promise<void>;
+12
View File
@@ -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);
}
+102
View File
@@ -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;
+10
View File
@@ -11,18 +11,28 @@ export function CreatePlaylistWithTracks(arg1:string,arg2:Array<string>):Promise
export function DeletePlaylist(arg1:number):Promise<void>;
export function FindPhantomMatches(arg1:number,arg2:Array<string>):Promise<playlist.PhantomSearchResult>;
export function GetAllPlaylists():Promise<Array<playlist.Summary>>;
export function GetAllPlaylistsWithTracks():Promise<Array<playlist.WithTracks>>;
export function GetPhantomCandidates(arg1:number,arg2:string):Promise<Array<playlist.CandidateTrack>>;
export function GetPlaylistTracks(arg1:number):Promise<Array<playlist.Track>>;
export function ImportPlaylist(arg1:string):Promise<playlist.Summary>;
export function RemovePhantomTracks(arg1:number,arg2:Array<string>):Promise<void>;
export function RemoveTracksFromPlaylist(arg1:number,arg2:Array<number>):Promise<void>;
export function RenamePlaylist(arg1:number,arg2:string):Promise<void>;
export function ResolvePhantomTracks(arg1:number,arg2:Record<string, string>):Promise<void>;
export function RestoreAllPlaylists():Promise<void>;
export function SearchLibrary(arg1:string):Promise<Array<playlist.CandidateTrack>>;
export function SetContext(arg1:context.Context):Promise<void>;
+20
View File
@@ -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);
}
View File
Vendored Regular → Executable
View File
View File