playlist phantom track matching added, updated search queries for efficiency
This commit is contained in:
@@ -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 |
|
||||||
@@ -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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
-- name: CreateAudioFile :one
|
-- 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 *;
|
RETURNING *;
|
||||||
|
|
||||||
-- name: GetAudioFile :one
|
-- name: GetAudioFile :one
|
||||||
@@ -12,7 +12,7 @@ WHERE file_path = ? LIMIT 1;
|
|||||||
|
|
||||||
-- name: UpdateAudioFile :exec
|
-- name: UpdateAudioFile :exec
|
||||||
UPDATE audio_files
|
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 = ?;
|
WHERE id = ?;
|
||||||
|
|
||||||
-- name: UpdateAudioFileRecording :exec
|
-- 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 release_groups rg ON rgr.release_group_id = rg.id
|
||||||
LEFT JOIN file_types ft ON af.file_type_id = ft.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
|
-- name: DeleteAllAudioFiles :exec
|
||||||
DELETE FROM audio_files;
|
DELETE FROM audio_files;
|
||||||
|
|
||||||
|
|||||||
@@ -22,3 +22,50 @@ DELETE FROM recording_genres;
|
|||||||
|
|
||||||
-- name: DeleteAllGenres :exec
|
-- name: DeleteAllGenres :exec
|
||||||
DELETE FROM genres;
|
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,
|
channels int NOT NULL DEFAULT 0,
|
||||||
bitrate int NOT NULL DEFAULT 0,
|
bitrate int NOT NULL DEFAULT 0,
|
||||||
file_size 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(file_type_id) REFERENCES file_types(id),
|
||||||
FOREIGN KEY(recording_id) REFERENCES recordings(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
|
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 (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
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
|
RETURNING id, file_path, length_milliseconds, file_type_id, recording_id, sample_rate, bit_depth, channels, bitrate, file_size, basename
|
||||||
`
|
`
|
||||||
|
|
||||||
type CreateAudioFileParams struct {
|
type CreateAudioFileParams struct {
|
||||||
@@ -36,6 +36,7 @@ type CreateAudioFileParams struct {
|
|||||||
Channels int64
|
Channels int64
|
||||||
Bitrate int64
|
Bitrate int64
|
||||||
FileSize int64
|
FileSize int64
|
||||||
|
Basename string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) CreateAudioFile(ctx context.Context, arg CreateAudioFileParams) (AudioFile, error) {
|
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.Channels,
|
||||||
arg.Bitrate,
|
arg.Bitrate,
|
||||||
arg.FileSize,
|
arg.FileSize,
|
||||||
|
arg.Basename,
|
||||||
)
|
)
|
||||||
var i AudioFile
|
var i AudioFile
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
@@ -62,6 +64,7 @@ func (q *Queries) CreateAudioFile(ctx context.Context, arg CreateAudioFileParams
|
|||||||
&i.Channels,
|
&i.Channels,
|
||||||
&i.Bitrate,
|
&i.Bitrate,
|
||||||
&i.FileSize,
|
&i.FileSize,
|
||||||
|
&i.Basename,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -118,7 +121,7 @@ func (q *Queries) GetAllAudioFilePaths(ctx context.Context) ([]GetAllAudioFilePa
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getAllAudioFiles = `-- name: GetAllAudioFiles :many
|
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) {
|
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.Channels,
|
||||||
&i.Bitrate,
|
&i.Bitrate,
|
||||||
&i.FileSize,
|
&i.FileSize,
|
||||||
|
&i.Basename,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -302,7 +306,7 @@ func (q *Queries) GetAllTracksWithFullMetadata(ctx context.Context) ([]GetAllTra
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getAudioFile = `-- name: GetAudioFile :one
|
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
|
WHERE id = ? LIMIT 1
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -320,12 +324,13 @@ func (q *Queries) GetAudioFile(ctx context.Context, id int64) (AudioFile, error)
|
|||||||
&i.Channels,
|
&i.Channels,
|
||||||
&i.Bitrate,
|
&i.Bitrate,
|
||||||
&i.FileSize,
|
&i.FileSize,
|
||||||
|
&i.Basename,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const getAudioFileByPath = `-- name: GetAudioFileByPath :one
|
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
|
WHERE file_path = ? LIMIT 1
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -343,6 +348,7 @@ func (q *Queries) GetAudioFileByPath(ctx context.Context, filePath string) (Audi
|
|||||||
&i.Channels,
|
&i.Channels,
|
||||||
&i.Bitrate,
|
&i.Bitrate,
|
||||||
&i.FileSize,
|
&i.FileSize,
|
||||||
|
&i.Basename,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -403,7 +409,7 @@ func (q *Queries) GetAudioFilesByReleaseGroup(ctx context.Context, releaseGroupI
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getAudioFilesNeedingMetadata = `-- name: GetAudioFilesNeedingMetadata :many
|
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
|
WHERE recording_id = 0
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -427,6 +433,7 @@ func (q *Queries) GetAudioFilesNeedingMetadata(ctx context.Context) ([]AudioFile
|
|||||||
&i.Channels,
|
&i.Channels,
|
||||||
&i.Bitrate,
|
&i.Bitrate,
|
||||||
&i.FileSize,
|
&i.FileSize,
|
||||||
|
&i.Basename,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -492,9 +499,71 @@ func (q *Queries) GetTrackMetadataByPath(ctx context.Context, filePath string) (
|
|||||||
return i, err
|
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
|
const updateAudioFile = `-- name: UpdateAudioFile :exec
|
||||||
UPDATE audio_files
|
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 = ?
|
WHERE id = ?
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -508,6 +577,7 @@ type UpdateAudioFileParams struct {
|
|||||||
Channels int64
|
Channels int64
|
||||||
Bitrate int64
|
Bitrate int64
|
||||||
FileSize int64
|
FileSize int64
|
||||||
|
Basename string
|
||||||
ID int64
|
ID int64
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -522,6 +592,7 @@ func (q *Queries) UpdateAudioFile(ctx context.Context, arg UpdateAudioFileParams
|
|||||||
arg.Channels,
|
arg.Channels,
|
||||||
arg.Bitrate,
|
arg.Bitrate,
|
||||||
arg.FileSize,
|
arg.FileSize,
|
||||||
|
arg.Basename,
|
||||||
arg.ID,
|
arg.ID,
|
||||||
)
|
)
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ package sqlcgen
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
)
|
)
|
||||||
|
|
||||||
const createRecordingGenre = `-- name: CreateRecordingGenre :exec
|
const createRecordingGenre = `-- name: CreateRecordingGenre :exec
|
||||||
@@ -52,6 +53,42 @@ func (q *Queries) DeleteRecordingGenres(ctx context.Context, recordingID int64)
|
|||||||
return err
|
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
|
const getGenresByRecordingID = `-- name: GetGenresByRecordingID :many
|
||||||
SELECT g.id, g.name
|
SELECT g.id, g.name
|
||||||
FROM genres g
|
FROM genres g
|
||||||
@@ -82,6 +119,106 @@ func (q *Queries) GetGenresByRecordingID(ctx context.Context, recordingID int64)
|
|||||||
return items, nil
|
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
|
const upsertGenre = `-- name: UpsertGenre :one
|
||||||
INSERT INTO genres (name) VALUES (?)
|
INSERT INTO genres (name) VALUES (?)
|
||||||
ON CONFLICT(name) DO UPDATE SET name = name
|
ON CONFLICT(name) DO UPDATE SET name = name
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ type AudioFile struct {
|
|||||||
Channels int64
|
Channels int64
|
||||||
Bitrate int64
|
Bitrate int64
|
||||||
FileSize int64
|
FileSize int64
|
||||||
|
Basename string
|
||||||
}
|
}
|
||||||
|
|
||||||
type CoverArt struct {
|
type CoverArt struct {
|
||||||
@@ -128,3 +129,10 @@ type ReleaseGroupRecording struct {
|
|||||||
TrackNumber sql.NullInt64
|
TrackNumber sql.NullInt64
|
||||||
DiscNumber sql.NullInt64
|
DiscNumber sql.NullInt64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SearchIndex struct {
|
||||||
|
FilePath string
|
||||||
|
Title string
|
||||||
|
Artist string
|
||||||
|
Album string
|
||||||
|
}
|
||||||
|
|||||||
@@ -485,6 +485,17 @@ func (l *Library) Scan() (*ScanMetrics, error) {
|
|||||||
return true
|
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)
|
removed.Add(1)
|
||||||
|
|
||||||
return true
|
return true
|
||||||
@@ -652,7 +663,7 @@ func (l *Library) commitBatch(
|
|||||||
|
|
||||||
if result.needsUpdate {
|
if result.needsUpdate {
|
||||||
saveErr = l.updateAudioFileMetadata(
|
saveErr = l.updateAudioFileMetadata(
|
||||||
txq, cache, metrics, *result,
|
txq, tx, cache, metrics, *result,
|
||||||
thumbChan,
|
thumbChan,
|
||||||
)
|
)
|
||||||
if saveErr == nil {
|
if saveErr == nil {
|
||||||
@@ -660,7 +671,7 @@ func (l *Library) commitBatch(
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
saveErr = l.saveAudioFile(
|
saveErr = l.saveAudioFile(
|
||||||
txq, cache, metrics, *result,
|
txq, tx, cache, metrics, *result,
|
||||||
thumbChan,
|
thumbChan,
|
||||||
)
|
)
|
||||||
if saveErr == nil {
|
if saveErr == nil {
|
||||||
@@ -692,6 +703,7 @@ func (l *Library) commitBatch(
|
|||||||
// saveAudioFile writes audio file metadata to the database (new files).
|
// saveAudioFile writes audio file metadata to the database (new files).
|
||||||
func (l *Library) saveAudioFile(
|
func (l *Library) saveAudioFile(
|
||||||
q *sqlcgen.Queries,
|
q *sqlcgen.Queries,
|
||||||
|
tx *sql.Tx,
|
||||||
cache *entityCache,
|
cache *entityCache,
|
||||||
metrics *ScanMetrics,
|
metrics *ScanMetrics,
|
||||||
result importResult,
|
result importResult,
|
||||||
@@ -722,7 +734,14 @@ func (l *Library) saveAudioFile(
|
|||||||
props = &metadata.AudioProperties{}
|
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{
|
l.ctx, sqlcgen.CreateAudioFileParams{
|
||||||
FilePath: result.absolutePath,
|
FilePath: result.absolutePath,
|
||||||
LengthMilliseconds: result.lengthMillis,
|
LengthMilliseconds: result.lengthMillis,
|
||||||
@@ -738,12 +757,37 @@ func (l *Library) saveAudioFile(
|
|||||||
Channels: int64(props.Channels),
|
Channels: int64(props.Channels),
|
||||||
Bitrate: int64(props.Bitrate),
|
Bitrate: int64(props.Bitrate),
|
||||||
FileSize: props.FileSize,
|
FileSize: props.FileSize,
|
||||||
}); err != nil {
|
Basename: basename,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"could not save audio file to db: %w", err,
|
"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(
|
l.logger.Debug(
|
||||||
"added audio file to library",
|
"added audio file to library",
|
||||||
"path", result.absolutePath,
|
"path", result.absolutePath,
|
||||||
@@ -755,6 +799,7 @@ func (l *Library) saveAudioFile(
|
|||||||
// updateAudioFileMetadata updates an existing audio file with extracted metadata.
|
// updateAudioFileMetadata updates an existing audio file with extracted metadata.
|
||||||
func (l *Library) updateAudioFileMetadata(
|
func (l *Library) updateAudioFileMetadata(
|
||||||
q *sqlcgen.Queries,
|
q *sqlcgen.Queries,
|
||||||
|
tx *sql.Tx,
|
||||||
cache *entityCache,
|
cache *entityCache,
|
||||||
metrics *ScanMetrics,
|
metrics *ScanMetrics,
|
||||||
result importResult,
|
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(
|
l.logger.Debug(
|
||||||
"updated audio file metadata",
|
"updated audio file metadata",
|
||||||
"path", result.absolutePath,
|
"path", result.absolutePath,
|
||||||
|
|||||||
+183
-22
@@ -1,6 +1,7 @@
|
|||||||
package library
|
package library
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -48,6 +49,39 @@ func splitGenres(concatenated string) []string {
|
|||||||
return strings.Split(concatenated, genreDelimiter)
|
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.
|
// Artist represents an artist in the library.
|
||||||
type Artist struct {
|
type Artist struct {
|
||||||
ID int64
|
ID int64
|
||||||
@@ -91,28 +125,24 @@ func (l *Library) GetAllTracks() ([]Track, error) {
|
|||||||
tracks := make([]Track, 0, len(rows))
|
tracks := make([]Track, 0, len(rows))
|
||||||
|
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
track := Track{
|
tracks = append(tracks, mapTrackRow(
|
||||||
TrackName: row.Title,
|
row.FilePath,
|
||||||
ArtistName: row.ArtistName,
|
row.LengthMilliseconds,
|
||||||
TrackLength: strconv.FormatInt(
|
row.Title,
|
||||||
row.LengthMilliseconds, 10,
|
row.ArtistName,
|
||||||
),
|
row.TrackNumber,
|
||||||
FilePath: row.FilePath,
|
row.DiscNumber,
|
||||||
TrackNumber: row.TrackNumber.Int64,
|
row.Album,
|
||||||
DiscNumber: row.DiscNumber.Int64,
|
row.Genre,
|
||||||
Album: row.Album,
|
row.Year,
|
||||||
Genre: splitGenres(row.Genre),
|
row.Composer,
|
||||||
Year: row.Year,
|
row.FileType,
|
||||||
Composer: row.Composer,
|
row.SampleRate,
|
||||||
FileType: row.FileType,
|
row.BitDepth,
|
||||||
SampleRate: row.SampleRate,
|
row.Channels,
|
||||||
BitDepth: row.BitDepth,
|
row.Bitrate,
|
||||||
Channels: row.Channels,
|
row.FileSize,
|
||||||
Bitrate: row.Bitrate,
|
))
|
||||||
FileSize: row.FileSize,
|
|
||||||
}
|
|
||||||
|
|
||||||
tracks = append(tracks, track)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
l.logger.Info("formatted tracks", "count", len(tracks))
|
l.logger.Info("formatted tracks", "count", len(tracks))
|
||||||
@@ -120,6 +150,56 @@ func (l *Library) GetAllTracks() ([]Track, error) {
|
|||||||
return tracks, nil
|
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.
|
// GetAlbumTracks returns all tracks for a given album (release group), ordered by disc and track number.
|
||||||
func (l *Library) GetAlbumTracks(albumID int64) ([]Track, error) {
|
func (l *Library) GetAlbumTracks(albumID int64) ([]Track, error) {
|
||||||
rows, err := l.db.Queries.GetAudioFilesByReleaseGroup(l.ctx, albumID)
|
rows, err := l.db.Queries.GetAudioFilesByReleaseGroup(l.ctx, albumID)
|
||||||
@@ -280,3 +360,84 @@ func (l *Library) GetAlbumsByArtist(
|
|||||||
|
|
||||||
return albums, nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
if err := tx.Commit(); err != nil {
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"could not commit library clear transaction: %w", err,
|
"could not commit library clear transaction: %w", err,
|
||||||
|
|||||||
+81
-9
@@ -19,7 +19,6 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
errInvalidM3U = errors.New("invalid M3U file: missing #EXTM3U header")
|
|
||||||
errEmptyM3UFile = errors.New("M3U file is empty")
|
errEmptyM3UFile = errors.New("M3U file is empty")
|
||||||
errPlaylistDirNil = errors.New("playlists directory path is empty")
|
errPlaylistDirNil = errors.New("playlists directory path is empty")
|
||||||
)
|
)
|
||||||
@@ -132,15 +131,15 @@ func parseM3U8(filePath string) (parsedPlaylist, error) {
|
|||||||
continue
|
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 !headerSeen {
|
||||||
if line == m3uHeader {
|
headerSeen = true
|
||||||
headerSeen = true
|
|
||||||
|
|
||||||
|
if line == m3uHeader {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
return parsedPlaylist{}, errInvalidM3U
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Playlist name directive.
|
// Playlist name directive.
|
||||||
@@ -300,11 +299,16 @@ func findPlaylistFile(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(matches) == 0 {
|
// Filter matches to ensure the extracted ID matches the
|
||||||
return "", nil
|
// 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
|
// removeOldPlaylistFile removes an old playlist file for the given
|
||||||
@@ -409,6 +413,74 @@ func extractPlaylistID(filePath string) int64 {
|
|||||||
return id
|
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.
|
// displayTitle builds an EXTINF display title from artist and title.
|
||||||
func displayTitle(artist, title string) string {
|
func displayTitle(artist, title string) string {
|
||||||
artist = strings.TrimSpace(artist)
|
artist = strings.TrimSpace(artist)
|
||||||
|
|||||||
@@ -201,25 +201,24 @@ func TestWriteM3U8EmptyDir(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseM3U8InvalidFile(t *testing.T) {
|
func TestParseM3U8EmptyFile(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
dir := t.TempDir()
|
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(
|
err := os.WriteFile(
|
||||||
badFile,
|
emptyFile,
|
||||||
[]byte("just some text\n"),
|
[]byte(""),
|
||||||
0o644,
|
0o644,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("could not write test file: %v", err)
|
t.Fatalf("could not write test file: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = parseM3U8(badFile)
|
_, err = parseM3U8(emptyFile)
|
||||||
if err == nil {
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -67,6 +68,32 @@ type WithTracks struct {
|
|||||||
Tracks []Track `json:"Tracks"`
|
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.
|
// Service manages playlist operations.
|
||||||
type Service struct {
|
type Service struct {
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
@@ -679,9 +706,10 @@ func (s *Service) ImportPlaylist(
|
|||||||
var (
|
var (
|
||||||
resolved int
|
resolved int
|
||||||
unresolved int
|
unresolved int
|
||||||
|
position int
|
||||||
)
|
)
|
||||||
|
|
||||||
for i, entry := range parsed.Entries {
|
for _, entry := range parsed.Entries {
|
||||||
absPath := toAbsolutePath(
|
absPath := toAbsolutePath(
|
||||||
entry.RelativePath, libraryRoot,
|
entry.RelativePath, libraryRoot,
|
||||||
)
|
)
|
||||||
@@ -701,7 +729,7 @@ func (s *Service) ImportPlaylist(
|
|||||||
sqlcgen.AddPlaylistTrackParams{
|
sqlcgen.AddPlaylistTrackParams{
|
||||||
PlaylistID: created.ID,
|
PlaylistID: created.ID,
|
||||||
AudioFileID: audioFile.ID,
|
AudioFileID: audioFile.ID,
|
||||||
Position: int64(i),
|
Position: int64(position),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if addErr != nil {
|
if addErr != nil {
|
||||||
@@ -715,6 +743,7 @@ func (s *Service) ImportPlaylist(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
position++
|
||||||
resolved++
|
resolved++
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -834,7 +863,9 @@ func (s *Service) restoreSinglePlaylist(
|
|||||||
return 0, 0
|
return 0, 0
|
||||||
}
|
}
|
||||||
|
|
||||||
for i, entry := range parsed.Entries {
|
var position int
|
||||||
|
|
||||||
|
for _, entry := range parsed.Entries {
|
||||||
absPath := toAbsolutePath(
|
absPath := toAbsolutePath(
|
||||||
entry.RelativePath, libraryRoot,
|
entry.RelativePath, libraryRoot,
|
||||||
)
|
)
|
||||||
@@ -853,7 +884,7 @@ func (s *Service) restoreSinglePlaylist(
|
|||||||
sqlcgen.AddPlaylistTrackParams{
|
sqlcgen.AddPlaylistTrackParams{
|
||||||
PlaylistID: playlistID,
|
PlaylistID: playlistID,
|
||||||
AudioFileID: audioFile.ID,
|
AudioFileID: audioFile.ID,
|
||||||
Position: int64(i),
|
Position: int64(position),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if addErr != nil {
|
if addErr != nil {
|
||||||
@@ -867,6 +898,7 @@ func (s *Service) restoreSinglePlaylist(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
position++
|
||||||
restored++
|
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,
|
state,
|
||||||
} from 'lit/decorators.js';
|
} from 'lit/decorators.js';
|
||||||
import { library } from '@go/models';
|
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 '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||||
import '@components/track-list/track-list.js';
|
import '@components/track-list/track-list.js';
|
||||||
|
|
||||||
@@ -20,10 +22,8 @@ export class GenreDetails extends LitElement {
|
|||||||
@state()
|
@state()
|
||||||
private loading = true;
|
private loading = true;
|
||||||
|
|
||||||
private libraryCtrl = new LibraryController(this);
|
private scanCompleteCleanup: (() => void) | null =
|
||||||
|
null;
|
||||||
/** Tracks the store's cached array reference to detect refreshes. */
|
|
||||||
private lastTracksRef: library.Track[] | null = null;
|
|
||||||
|
|
||||||
static override styles = css`
|
static override styles = css`
|
||||||
:host {
|
:host {
|
||||||
@@ -151,17 +151,19 @@ export class GenreDetails extends LitElement {
|
|||||||
override connectedCallback() {
|
override connectedCallback() {
|
||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
this.loadTracks();
|
this.loadTracks();
|
||||||
|
|
||||||
|
this.scanCompleteCleanup = EventsOn(
|
||||||
|
Events.LibraryScanComplete,
|
||||||
|
() => this.loadTracks(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
override updated() {
|
override disconnectedCallback() {
|
||||||
const cached = this.libraryCtrl.cachedTracks;
|
super.disconnectedCallback();
|
||||||
|
|
||||||
if (
|
if (this.scanCompleteCleanup) {
|
||||||
cached !== null &&
|
this.scanCompleteCleanup();
|
||||||
cached !== this.lastTracksRef
|
this.scanCompleteCleanup = null;
|
||||||
) {
|
|
||||||
this.lastTracksRef = cached;
|
|
||||||
this.loadTracks();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,14 +175,8 @@ export class GenreDetails extends LitElement {
|
|||||||
if (!this.genreName) return;
|
if (!this.genreName) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const allTracks =
|
this.tracks = await GetTracksByGenre(
|
||||||
await this.libraryCtrl.getTracks();
|
this.genreName,
|
||||||
|
|
||||||
this.tracks = (allTracks ?? []).filter(
|
|
||||||
(t) =>
|
|
||||||
(t.Genre ?? []).includes(
|
|
||||||
this.genreName,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error(
|
||||||
|
|||||||
@@ -10,7 +10,12 @@ import type {
|
|||||||
VisibilityChangedEvent,
|
VisibilityChangedEvent,
|
||||||
} from '@lit-labs/virtualizer';
|
} from '@lit-labs/virtualizer';
|
||||||
import { grid } from '@lit-labs/virtualizer/layouts/grid.js';
|
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 { LibraryController } from '@store/controllers/library-controller';
|
||||||
import { SearchController } from '@store/controllers/search-controller';
|
import { SearchController } from '@store/controllers/search-controller';
|
||||||
import { queueStore } from '@store/queue-store';
|
import { queueStore } from '@store/queue-store';
|
||||||
@@ -61,17 +66,13 @@ export class GenresView
|
|||||||
private ctxMenu = new ContextMenuController(this);
|
private ctxMenu = new ContextMenuController(this);
|
||||||
private wheelListenerAttached = false;
|
private wheelListenerAttached = false;
|
||||||
private lastSearchTerm = '';
|
private lastSearchTerm = '';
|
||||||
|
private scanCompleteCleanup: (() => void) | null =
|
||||||
/** Tracks the store's cached array reference to detect refreshes. */
|
|
||||||
private lastTracksRef: library.Track[] | null =
|
|
||||||
null;
|
null;
|
||||||
|
|
||||||
private scrollDebounceTimer: ReturnType<
|
private scrollDebounceTimer: ReturnType<
|
||||||
typeof setTimeout
|
typeof setTimeout
|
||||||
> | null = null;
|
> | null = null;
|
||||||
|
|
||||||
/** All tracks from the library (used to derive genres). */
|
|
||||||
private allTracks: library.Track[] = [];
|
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
private genres: Genre[] = [];
|
private genres: Genre[] = [];
|
||||||
|
|
||||||
@@ -378,12 +379,22 @@ export class GenresView
|
|||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
this.loadCardSize();
|
this.loadCardSize();
|
||||||
this.loadGenres();
|
this.loadGenres();
|
||||||
|
|
||||||
|
this.scanCompleteCleanup = EventsOn(
|
||||||
|
Events.LibraryScanComplete,
|
||||||
|
() => this.loadGenres(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
override disconnectedCallback() {
|
override disconnectedCallback() {
|
||||||
super.disconnectedCallback();
|
super.disconnectedCallback();
|
||||||
this.detachWheelListener();
|
this.detachWheelListener();
|
||||||
|
|
||||||
|
if (this.scanCompleteCleanup) {
|
||||||
|
this.scanCompleteCleanup();
|
||||||
|
this.scanCompleteCleanup = null;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.scrollDebounceTimer !== null) {
|
if (this.scrollDebounceTimer !== null) {
|
||||||
clearTimeout(this.scrollDebounceTimer);
|
clearTimeout(this.scrollDebounceTimer);
|
||||||
}
|
}
|
||||||
@@ -401,19 +412,6 @@ export class GenresView
|
|||||||
this.lastSearchTerm = currentTerm;
|
this.lastSearchTerm = currentTerm;
|
||||||
this.clearSelection();
|
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 {
|
try {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
|
|
||||||
const tracks =
|
const rows =
|
||||||
await this.libraryCtrl.getTracks();
|
await GetAllGenresWithCounts();
|
||||||
|
|
||||||
this.allTracks = tracks ?? [];
|
this.genres = (rows ?? []).map((r) => ({
|
||||||
this.genres =
|
name: r.Name,
|
||||||
this.extractGenres(this.allTracks);
|
trackCount: r.TrackCount,
|
||||||
|
}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error(
|
||||||
'Error loading genres:',
|
'Error loading genres:',
|
||||||
error,
|
error,
|
||||||
);
|
);
|
||||||
this.allTracks = [];
|
|
||||||
this.genres = [];
|
this.genres = [];
|
||||||
} finally {
|
} finally {
|
||||||
const saved =
|
const saved =
|
||||||
@@ -451,41 +449,6 @@ export class GenresView
|
|||||||
this.restoreScrollPosition();
|
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
|
* Scroll position persistence
|
||||||
* ================================================================ */
|
* ================================================================ */
|
||||||
@@ -743,24 +706,28 @@ export class GenresView
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns all file paths for every selected
|
* Fetch file paths for a set of genre names by
|
||||||
* genre.
|
* querying the backend for each genre.
|
||||||
*/
|
*/
|
||||||
private getSelectedGenreFilePaths(): string[] {
|
private async getFilePathsForGenres(
|
||||||
const allPaths: string[] = [];
|
genreNames: Iterable<string>,
|
||||||
|
): Promise<string[]> {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
|
const allPaths: string[] = [];
|
||||||
|
|
||||||
for (const track of this.allTracks) {
|
const promises = Array.from(
|
||||||
if (seen.has(track.FilePath)) continue;
|
genreNames,
|
||||||
|
(name) => GetTracksByGenre(name),
|
||||||
|
);
|
||||||
|
|
||||||
const genres = track.Genre ?? [];
|
const results = await Promise.all(promises);
|
||||||
const match = genres.some((g) =>
|
|
||||||
this.selectedGenres.has(g),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (match) {
|
for (const tracks of results) {
|
||||||
allPaths.push(track.FilePath);
|
for (const track of tracks ?? []) {
|
||||||
seen.add(track.FilePath);
|
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
|
* genres. Otherwise return paths for the
|
||||||
* right-clicked genre only.
|
* right-clicked genre only.
|
||||||
*/
|
*/
|
||||||
private getContextMenuGenreFilePaths(): string[] {
|
private async getContextMenuGenreFilePaths(): Promise<
|
||||||
|
string[]
|
||||||
|
> {
|
||||||
if (
|
if (
|
||||||
this.contextMenuGenreName !== null &&
|
this.contextMenuGenreName !== null &&
|
||||||
!this.selectedGenres.has(
|
!this.selectedGenres.has(
|
||||||
this.contextMenuGenreName,
|
this.contextMenuGenreName,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
const paths: string[] = [];
|
return this.getFilePathsForGenres([
|
||||||
const seen = new Set<string>();
|
this.contextMenuGenreName,
|
||||||
|
]);
|
||||||
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.getSelectedGenreFilePaths();
|
return this.getFilePathsForGenres(
|
||||||
|
this.selectedGenres,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Clear the current genre selection. */
|
/** Clear the current genre selection. */
|
||||||
@@ -889,9 +843,11 @@ export class GenresView
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
private onContextMenuAction(action: string) {
|
private async onContextMenuAction(
|
||||||
|
action: string,
|
||||||
|
) {
|
||||||
const filePaths =
|
const filePaths =
|
||||||
this.getContextMenuGenreFilePaths();
|
await this.getContextMenuGenreFilePaths();
|
||||||
|
|
||||||
if (filePaths.length === 0) return;
|
if (filePaths.length === 0) return;
|
||||||
|
|
||||||
@@ -1063,8 +1019,11 @@ export class GenresView
|
|||||||
class="submenu-item"
|
class="submenu-item"
|
||||||
@mouseenter=${() => {
|
@mouseenter=${() => {
|
||||||
this.ctxMenu.clearSubmenuCloseTimer();
|
this.ctxMenu.clearSubmenuCloseTimer();
|
||||||
void this.ctxMenu.showPlaylistSubmenu(
|
void this.getContextMenuGenreFilePaths().then(
|
||||||
this.getContextMenuGenreFilePaths(),
|
(paths) =>
|
||||||
|
this.ctxMenu.showPlaylistSubmenu(
|
||||||
|
paths,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
@mouseleave=${this
|
@mouseleave=${this
|
||||||
@@ -1074,8 +1033,11 @@ export class GenresView
|
|||||||
e: Event,
|
e: Event,
|
||||||
) => {
|
) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
void this.ctxMenu.showPlaylistSubmenu(
|
void this.getContextMenuGenreFilePaths().then(
|
||||||
this.getContextMenuGenreFilePaths(),
|
(paths) =>
|
||||||
|
this.ctxMenu.showPlaylistSubmenu(
|
||||||
|
paths,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,7 @@ import {
|
|||||||
DeletePlaylist,
|
DeletePlaylist,
|
||||||
RenamePlaylist,
|
RenamePlaylist,
|
||||||
ImportPlaylist,
|
ImportPlaylist,
|
||||||
|
RemovePhantomTracks,
|
||||||
} from '@go/playlist/Service';
|
} from '@go/playlist/Service';
|
||||||
import { PlaylistFilePicker } from '@go/frontendutil/FrontendUtil';
|
import { PlaylistFilePicker } from '@go/frontendutil/FrontendUtil';
|
||||||
import type { playlist } from '@go/models';
|
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 '@components/track-details/track-details.js';
|
||||||
import type { TrackDetails } from '@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 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;
|
const SCROLL_DEBOUNCE_MS = 100;
|
||||||
|
|
||||||
@@ -191,6 +194,9 @@ export class PlaylistView
|
|||||||
/** True when dragging over the "New Playlist" button. */
|
/** True when dragging over the "New Playlist" button. */
|
||||||
@state() private dragOverNewButton = false;
|
@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.
|
* File paths from a drop that landed outside any playlist.
|
||||||
* When non-empty the create form is in "create-and-add" mode.
|
* When non-empty the create form is in "create-and-add" mode.
|
||||||
@@ -211,6 +217,9 @@ export class PlaylistView
|
|||||||
@query('track-details')
|
@query('track-details')
|
||||||
private trackDetailsDialog!: TrackDetails;
|
private trackDetailsDialog!: TrackDetails;
|
||||||
|
|
||||||
|
@query('phantom-resolver')
|
||||||
|
private phantomResolver!: PhantomResolver;
|
||||||
|
|
||||||
private closePlaylistCtxMenuHandler =
|
private closePlaylistCtxMenuHandler =
|
||||||
() => this.closePlaylistContextMenu();
|
() => this.closePlaylistContextMenu();
|
||||||
|
|
||||||
@@ -579,23 +588,83 @@ export class PlaylistView
|
|||||||
}
|
}
|
||||||
|
|
||||||
.track-item.phantom {
|
.track-item.phantom {
|
||||||
opacity: 0.45;
|
cursor: pointer;
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.track-item.phantom:hover {
|
.track-item.phantom:hover {
|
||||||
background-color: transparent;
|
background-color: var(
|
||||||
|
--yj-hover-overlay,
|
||||||
|
rgba(255, 255, 255, 0.05)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
.phantom-badge {
|
.track-item.phantom.selected {
|
||||||
display: inline-block;
|
background-color: var(
|
||||||
font-size: 10px;
|
--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);
|
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;
|
border-radius: 3px;
|
||||||
margin-left: 8px;
|
font-size: 13px;
|
||||||
vertical-align: middle;
|
}
|
||||||
|
|
||||||
|
.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 {
|
.track-item:last-child {
|
||||||
@@ -747,6 +816,23 @@ export class PlaylistView
|
|||||||
border-color: var(--yj-accent, #ffd43b);
|
border-color: var(--yj-accent, #ffd43b);
|
||||||
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() {
|
override connectedCallback() {
|
||||||
@@ -1045,12 +1131,60 @@ export class PlaylistView
|
|||||||
case 'track-details':
|
case 'track-details':
|
||||||
this.openTrackDetails(filePaths[0]!);
|
this.openTrackDetails(filePaths[0]!);
|
||||||
break;
|
break;
|
||||||
|
case 'phantom-locate':
|
||||||
|
if (this.activePlaylistIndex >= 0) {
|
||||||
|
this.openPhantomResolver(
|
||||||
|
this.activePlaylistIndex,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
case 'phantom-remove':
|
||||||
|
void this.removeSelectedPhantoms();
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.selection.clear();
|
this.selection.clear();
|
||||||
this.ctxMenu.close();
|
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) {
|
private openTrackDetails(filePath: string) {
|
||||||
const tracks =
|
const tracks =
|
||||||
libraryStore.getCachedTracks();
|
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
|
// Import playlist
|
||||||
// =================================================================
|
// =================================================================
|
||||||
@@ -1585,13 +1856,20 @@ export class PlaylistView
|
|||||||
|
|
||||||
if (!filePath) return;
|
if (!filePath) return;
|
||||||
|
|
||||||
|
this.importError = '';
|
||||||
await ImportPlaylist(filePath);
|
await ImportPlaylist(filePath);
|
||||||
await this.refreshPlaylists();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(
|
console.error(
|
||||||
'Failed to import playlist:',
|
'Failed to import playlist:',
|
||||||
err,
|
err,
|
||||||
);
|
);
|
||||||
|
this.importError =
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: String(err);
|
||||||
|
setTimeout(() => {
|
||||||
|
this.importError = '';
|
||||||
|
}, 6000);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1707,6 +1985,12 @@ export class PlaylistView
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
${this.importError
|
||||||
|
? html`<div class="import-error">
|
||||||
|
${this.importError}
|
||||||
|
</div>`
|
||||||
|
: nothing}
|
||||||
|
|
||||||
${this.searchCtrl.term &&
|
${this.searchCtrl.term &&
|
||||||
this.filteredEntries.length > 0
|
this.filteredEntries.length > 0
|
||||||
? html`<div class="search-indicator">
|
? html`<div class="search-indicator">
|
||||||
@@ -1735,111 +2019,144 @@ export class PlaylistView
|
|||||||
.contextMenuOpen}
|
.contextMenuOpen}
|
||||||
>
|
>
|
||||||
${this.ctxMenu.contextMenuOpen
|
${this.ctxMenu.contextMenuOpen
|
||||||
? html`
|
? this.isPhantomSelection()
|
||||||
<div class="context-menu-panel">
|
? html`
|
||||||
<wa-dropdown-item
|
<div class="context-menu-panel">
|
||||||
@click=${() =>
|
<wa-dropdown-item
|
||||||
this.onContextMenuAction(
|
@click=${() =>
|
||||||
'play',
|
this.onContextMenuAction(
|
||||||
)}
|
'phantom-locate',
|
||||||
@mouseenter=${() =>
|
)}
|
||||||
this.ctxMenu.closePlaylistSubmenu()}
|
>
|
||||||
>
|
<wa-icon
|
||||||
<wa-icon
|
slot="icon"
|
||||||
slot="icon"
|
name="magnifying-glass"
|
||||||
name="play"
|
></wa-icon>
|
||||||
></wa-icon>
|
Locate in
|
||||||
Play
|
Library
|
||||||
</wa-dropdown-item>
|
</wa-dropdown-item>
|
||||||
<wa-dropdown-item
|
<wa-dropdown-item
|
||||||
@click=${() =>
|
@click=${() =>
|
||||||
this.onContextMenuAction(
|
this.onContextMenuAction(
|
||||||
'add-to-queue',
|
'phantom-remove',
|
||||||
)}
|
)}
|
||||||
@mouseenter=${() =>
|
>
|
||||||
this.ctxMenu.closePlaylistSubmenu()}
|
<wa-icon
|
||||||
>
|
slot="icon"
|
||||||
<wa-icon
|
name="trash"
|
||||||
slot="icon"
|
></wa-icon>
|
||||||
name="plus"
|
Remove from
|
||||||
></wa-icon>
|
Playlist
|
||||||
Add to Queue
|
</wa-dropdown-item>
|
||||||
</wa-dropdown-item>
|
</div>
|
||||||
<wa-dropdown-item
|
`
|
||||||
@click=${() =>
|
: html`
|
||||||
this.onContextMenuAction(
|
<div class="context-menu-panel">
|
||||||
'play-next',
|
<wa-dropdown-item
|
||||||
)}
|
@click=${() =>
|
||||||
@mouseenter=${() =>
|
this.onContextMenuAction(
|
||||||
this.ctxMenu.closePlaylistSubmenu()}
|
'play',
|
||||||
>
|
)}
|
||||||
<wa-icon
|
@mouseenter=${() =>
|
||||||
slot="icon"
|
this.ctxMenu.closePlaylistSubmenu()}
|
||||||
name="forward-step"
|
>
|
||||||
></wa-icon>
|
<wa-icon
|
||||||
Play Next
|
slot="icon"
|
||||||
</wa-dropdown-item>
|
name="play"
|
||||||
<wa-dropdown-item
|
></wa-icon>
|
||||||
@click=${() =>
|
Play
|
||||||
this.onContextMenuAction(
|
</wa-dropdown-item>
|
||||||
'remove',
|
<wa-dropdown-item
|
||||||
)}
|
@click=${() =>
|
||||||
@mouseenter=${() =>
|
this.onContextMenuAction(
|
||||||
this.ctxMenu.closePlaylistSubmenu()}
|
'add-to-queue',
|
||||||
>
|
)}
|
||||||
<wa-icon
|
@mouseenter=${() =>
|
||||||
slot="icon"
|
this.ctxMenu.closePlaylistSubmenu()}
|
||||||
name="trash"
|
>
|
||||||
></wa-icon>
|
<wa-icon
|
||||||
Remove from Playlist
|
slot="icon"
|
||||||
</wa-dropdown-item>
|
name="plus"
|
||||||
<wa-dropdown-item
|
></wa-icon>
|
||||||
class="submenu-item"
|
Add to Queue
|
||||||
@mouseenter=${() => {
|
</wa-dropdown-item>
|
||||||
this.ctxMenu.clearSubmenuCloseTimer();
|
<wa-dropdown-item
|
||||||
void this.ctxMenu.showPlaylistSubmenu(this.getSelectedFilePaths());
|
@click=${() =>
|
||||||
}}
|
this.onContextMenuAction(
|
||||||
@mouseleave=${this
|
'play-next',
|
||||||
.ctxMenu
|
)}
|
||||||
.scheduleSubmenuClose}
|
@mouseenter=${() =>
|
||||||
@click=${(e: Event) => {
|
this.ctxMenu.closePlaylistSubmenu()}
|
||||||
e.stopPropagation();
|
>
|
||||||
void this.ctxMenu.showPlaylistSubmenu(this.getSelectedFilePaths());
|
<wa-icon
|
||||||
}}
|
slot="icon"
|
||||||
>
|
name="forward-step"
|
||||||
<wa-icon
|
></wa-icon>
|
||||||
slot="icon"
|
Play Next
|
||||||
name="plus"
|
</wa-dropdown-item>
|
||||||
></wa-icon>
|
<wa-dropdown-item
|
||||||
Add to Playlist
|
@click=${() =>
|
||||||
<span
|
this.onContextMenuAction(
|
||||||
class="submenu-arrow"
|
'remove',
|
||||||
>
|
)}
|
||||||
▶
|
@mouseenter=${() =>
|
||||||
</span>
|
this.ctxMenu.closePlaylistSubmenu()}
|
||||||
</wa-dropdown-item>
|
>
|
||||||
${this.selection
|
<wa-icon
|
||||||
.selectionCount === 1
|
slot="icon"
|
||||||
? html`
|
name="trash"
|
||||||
<wa-dropdown-item
|
></wa-icon>
|
||||||
@click=${() =>
|
Remove from
|
||||||
this.onContextMenuAction(
|
Playlist
|
||||||
'track-details',
|
</wa-dropdown-item>
|
||||||
)}
|
<wa-dropdown-item
|
||||||
@mouseenter=${() =>
|
class="submenu-item"
|
||||||
this.ctxMenu.closePlaylistSubmenu()}
|
@mouseenter=${() => {
|
||||||
>
|
this.ctxMenu.clearSubmenuCloseTimer();
|
||||||
<wa-icon
|
void this.ctxMenu.showPlaylistSubmenu(this.getSelectedFilePaths());
|
||||||
slot="icon"
|
}}
|
||||||
name="circle-info"
|
@mouseleave=${this
|
||||||
></wa-icon>
|
.ctxMenu
|
||||||
Track
|
.scheduleSubmenuClose}
|
||||||
Details
|
@click=${(e: Event) => {
|
||||||
</wa-dropdown-item>
|
e.stopPropagation();
|
||||||
`
|
void this.ctxMenu.showPlaylistSubmenu(this.getSelectedFilePaths());
|
||||||
: nothing}
|
}}
|
||||||
</div>
|
>
|
||||||
`
|
<wa-icon
|
||||||
|
slot="icon"
|
||||||
|
name="plus"
|
||||||
|
></wa-icon>
|
||||||
|
Add to Playlist
|
||||||
|
<span
|
||||||
|
class="submenu-arrow"
|
||||||
|
>
|
||||||
|
▶
|
||||||
|
</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}
|
: nothing}
|
||||||
</wa-popup>
|
</wa-popup>
|
||||||
|
|
||||||
@@ -1917,6 +2234,10 @@ export class PlaylistView
|
|||||||
</wa-popup>
|
</wa-popup>
|
||||||
|
|
||||||
<track-details></track-details>
|
<track-details></track-details>
|
||||||
|
<phantom-resolver
|
||||||
|
@phantom-resolved=${() =>
|
||||||
|
this.refreshPlaylists()}
|
||||||
|
></phantom-resolver>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2153,7 +2474,6 @@ export class PlaylistView
|
|||||||
track,
|
track,
|
||||||
);
|
);
|
||||||
const selected =
|
const selected =
|
||||||
!isPhantom &&
|
|
||||||
this.activePlaylistIndex ===
|
this.activePlaylistIndex ===
|
||||||
playlistIndex &&
|
playlistIndex &&
|
||||||
this.selection.isSelected(
|
this.selection.isSelected(
|
||||||
@@ -2180,7 +2500,14 @@ export class PlaylistView
|
|||||||
? 'false'
|
? 'false'
|
||||||
: 'true'}
|
: 'true'}
|
||||||
@click=${isPhantom
|
@click=${isPhantom
|
||||||
? nothing
|
? (
|
||||||
|
e: MouseEvent,
|
||||||
|
) =>
|
||||||
|
this.handlePhantomClick(
|
||||||
|
e,
|
||||||
|
trackIndex,
|
||||||
|
playlistIndex,
|
||||||
|
)
|
||||||
: (
|
: (
|
||||||
e: MouseEvent,
|
e: MouseEvent,
|
||||||
) =>
|
) =>
|
||||||
@@ -2199,7 +2526,14 @@ export class PlaylistView
|
|||||||
playlistIndex,
|
playlistIndex,
|
||||||
)}
|
)}
|
||||||
@contextmenu=${isPhantom
|
@contextmenu=${isPhantom
|
||||||
? nothing
|
? (
|
||||||
|
e: MouseEvent,
|
||||||
|
) =>
|
||||||
|
this.handlePhantomContextMenu(
|
||||||
|
e,
|
||||||
|
trackIndex,
|
||||||
|
playlistIndex,
|
||||||
|
)
|
||||||
: (
|
: (
|
||||||
e: MouseEvent,
|
e: MouseEvent,
|
||||||
) =>
|
) =>
|
||||||
@@ -2224,20 +2558,67 @@ export class PlaylistView
|
|||||||
: this
|
: this
|
||||||
.onTrackDragEnd}
|
.onTrackDragEnd}
|
||||||
>
|
>
|
||||||
<track-info
|
|
||||||
.trackTitle=${track.Title ||
|
|
||||||
track.FilePath}
|
|
||||||
.artist=${track.Artist}
|
|
||||||
.duration=${track.Duration}
|
|
||||||
.filePath=${track.FilePath}
|
|
||||||
></track-info>
|
|
||||||
${isPhantom
|
${isPhantom
|
||||||
? html`<span
|
? html`<div
|
||||||
class="phantom-badge"
|
class="phantom-row"
|
||||||
>File not
|
>
|
||||||
found</span
|
<wa-icon
|
||||||
>`
|
class="phantom-caution"
|
||||||
: nothing}
|
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>
|
</div>
|
||||||
`;
|
`;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -48,10 +48,7 @@ export class TrackDetails extends LitElement {
|
|||||||
@state() private editValues: Record<string, string> = {};
|
@state() private editValues: Record<string, string> = {};
|
||||||
|
|
||||||
@query('wa-dialog')
|
@query('wa-dialog')
|
||||||
private dialog!: HTMLElement & {
|
private dialog!: HTMLElement & { open: boolean };
|
||||||
show: () => void;
|
|
||||||
hide: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
// =================================================================
|
// =================================================================
|
||||||
// PUBLIC API
|
// PUBLIC API
|
||||||
@@ -68,13 +65,13 @@ export class TrackDetails extends LitElement {
|
|||||||
this.editValues = {};
|
this.editValues = {};
|
||||||
|
|
||||||
this.updateComplete.then(() => {
|
this.updateComplete.then(() => {
|
||||||
this.dialog?.show();
|
if (this.dialog) this.dialog.open = true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Close the dialog. */
|
/** Close the dialog. */
|
||||||
close(): void {
|
close(): void {
|
||||||
this.dialog?.hide();
|
if (this.dialog) this.dialog.open = false;
|
||||||
this.editing = false;
|
this.editing = false;
|
||||||
this.editValues = {};
|
this.editValues = {};
|
||||||
}
|
}
|
||||||
|
|||||||
+6
@@ -13,10 +13,16 @@ export function GetAllAlbums():Promise<Array<library.Album>>;
|
|||||||
|
|
||||||
export function GetAllArtists():Promise<Array<library.Artist>>;
|
export function GetAllArtists():Promise<Array<library.Artist>>;
|
||||||
|
|
||||||
|
export function GetAllGenresWithCounts():Promise<Array<library.GenreWithCount>>;
|
||||||
|
|
||||||
export function GetAllTracks():Promise<Array<library.Track>>;
|
export function GetAllTracks():Promise<Array<library.Track>>;
|
||||||
|
|
||||||
|
export function GetTracksByGenre(arg1:string):Promise<Array<library.Track>>;
|
||||||
|
|
||||||
export function Scan():Promise<library.ScanMetrics>;
|
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 SetContext(arg1:context.Context):Promise<void>;
|
||||||
|
|
||||||
export function SetRescanHooks(arg1:library.RescanHooks):Promise<void>;
|
export function SetRescanHooks(arg1:library.RescanHooks):Promise<void>;
|
||||||
|
|||||||
@@ -22,14 +22,26 @@ export function GetAllArtists() {
|
|||||||
return window['go']['library']['Library']['GetAllArtists']();
|
return window['go']['library']['Library']['GetAllArtists']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetAllGenresWithCounts() {
|
||||||
|
return window['go']['library']['Library']['GetAllGenresWithCounts']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetAllTracks() {
|
export function GetAllTracks() {
|
||||||
return window['go']['library']['Library']['GetAllTracks']();
|
return window['go']['library']['Library']['GetAllTracks']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetTracksByGenre(arg1) {
|
||||||
|
return window['go']['library']['Library']['GetTracksByGenre'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function Scan() {
|
export function Scan() {
|
||||||
return window['go']['library']['Library']['Scan']();
|
return window['go']['library']['Library']['Scan']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SearchTracks(arg1) {
|
||||||
|
return window['go']['library']['Library']['SearchTracks'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SetContext(arg1) {
|
export function SetContext(arg1) {
|
||||||
return window['go']['library']['Library']['SetContext'](arg1);
|
return window['go']['library']['Library']['SetContext'](arg1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,20 @@ export namespace library {
|
|||||||
this.Name = source["Name"];
|
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 {
|
export class RescanHooks {
|
||||||
|
|
||||||
|
|
||||||
@@ -159,6 +173,94 @@ export namespace library {
|
|||||||
|
|
||||||
export namespace playlist {
|
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 {
|
export class Summary {
|
||||||
ID: number;
|
ID: number;
|
||||||
Name: string;
|
Name: string;
|
||||||
|
|||||||
+10
@@ -11,18 +11,28 @@ export function CreatePlaylistWithTracks(arg1:string,arg2:Array<string>):Promise
|
|||||||
|
|
||||||
export function DeletePlaylist(arg1:number):Promise<void>;
|
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 GetAllPlaylists():Promise<Array<playlist.Summary>>;
|
||||||
|
|
||||||
export function GetAllPlaylistsWithTracks():Promise<Array<playlist.WithTracks>>;
|
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 GetPlaylistTracks(arg1:number):Promise<Array<playlist.Track>>;
|
||||||
|
|
||||||
export function ImportPlaylist(arg1:string):Promise<playlist.Summary>;
|
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 RemoveTracksFromPlaylist(arg1:number,arg2:Array<number>):Promise<void>;
|
||||||
|
|
||||||
export function RenamePlaylist(arg1:number,arg2:string):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 RestoreAllPlaylists():Promise<void>;
|
||||||
|
|
||||||
|
export function SearchLibrary(arg1:string):Promise<Array<playlist.CandidateTrack>>;
|
||||||
|
|
||||||
export function SetContext(arg1:context.Context):Promise<void>;
|
export function SetContext(arg1:context.Context):Promise<void>;
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ export function DeletePlaylist(arg1) {
|
|||||||
return window['go']['playlist']['Service']['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() {
|
export function GetAllPlaylists() {
|
||||||
return window['go']['playlist']['Service']['GetAllPlaylists']();
|
return window['go']['playlist']['Service']['GetAllPlaylists']();
|
||||||
}
|
}
|
||||||
@@ -26,6 +30,10 @@ export function GetAllPlaylistsWithTracks() {
|
|||||||
return window['go']['playlist']['Service']['GetAllPlaylistsWithTracks']();
|
return window['go']['playlist']['Service']['GetAllPlaylistsWithTracks']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetPhantomCandidates(arg1, arg2) {
|
||||||
|
return window['go']['playlist']['Service']['GetPhantomCandidates'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
export function GetPlaylistTracks(arg1) {
|
export function GetPlaylistTracks(arg1) {
|
||||||
return window['go']['playlist']['Service']['GetPlaylistTracks'](arg1);
|
return window['go']['playlist']['Service']['GetPlaylistTracks'](arg1);
|
||||||
}
|
}
|
||||||
@@ -34,6 +42,10 @@ export function ImportPlaylist(arg1) {
|
|||||||
return window['go']['playlist']['Service']['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) {
|
export function RemoveTracksFromPlaylist(arg1, arg2) {
|
||||||
return window['go']['playlist']['Service']['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);
|
return window['go']['playlist']['Service']['RenamePlaylist'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ResolvePhantomTracks(arg1, arg2) {
|
||||||
|
return window['go']['playlist']['Service']['ResolvePhantomTracks'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
export function RestoreAllPlaylists() {
|
export function RestoreAllPlaylists() {
|
||||||
return window['go']['playlist']['Service']['RestoreAllPlaylists']();
|
return window['go']['playlist']['Service']['RestoreAllPlaylists']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SearchLibrary(arg1) {
|
||||||
|
return window['go']['playlist']['Service']['SearchLibrary'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SetContext(arg1) {
|
export function SetContext(arg1) {
|
||||||
return window['go']['playlist']['Service']['SetContext'](arg1);
|
return window['go']['playlist']['Service']['SetContext'](arg1);
|
||||||
}
|
}
|
||||||
|
|||||||
Regular → Executable
Regular → Executable
Reference in New Issue
Block a user