playlist phantom track matching added, updated search queries for efficiency

This commit is contained in:
2026-02-24 21:23:25 -05:00
parent ff7649600b
commit e192d4625e
31 changed files with 5222 additions and 316 deletions
+93 -4
View File
@@ -485,6 +485,17 @@ func (l *Library) Scan() (*ScanMetrics, error) {
return true
}
// Remove from FTS5 search index.
if err := l.db.DeleteSearchIndex(
audioFile.ID,
); err != nil {
l.logger.Warn(
"failed to delete FTS entry for orphan",
"id", audioFile.ID,
"err", err,
)
}
removed.Add(1)
return true
@@ -652,7 +663,7 @@ func (l *Library) commitBatch(
if result.needsUpdate {
saveErr = l.updateAudioFileMetadata(
txq, cache, metrics, *result,
txq, tx, cache, metrics, *result,
thumbChan,
)
if saveErr == nil {
@@ -660,7 +671,7 @@ func (l *Library) commitBatch(
}
} else {
saveErr = l.saveAudioFile(
txq, cache, metrics, *result,
txq, tx, cache, metrics, *result,
thumbChan,
)
if saveErr == nil {
@@ -692,6 +703,7 @@ func (l *Library) commitBatch(
// saveAudioFile writes audio file metadata to the database (new files).
func (l *Library) saveAudioFile(
q *sqlcgen.Queries,
tx *sql.Tx,
cache *entityCache,
metrics *ScanMetrics,
result importResult,
@@ -722,7 +734,14 @@ func (l *Library) saveAudioFile(
props = &metadata.AudioProperties{}
}
if _, err := q.CreateAudioFile(
tags := result.tags
if tags == nil {
tags = &metadata.TrackMetadata{}
}
basename := filepath.Base(result.absolutePath)
af, err := q.CreateAudioFile(
l.ctx, sqlcgen.CreateAudioFileParams{
FilePath: result.absolutePath,
LengthMilliseconds: result.lengthMillis,
@@ -738,12 +757,37 @@ func (l *Library) saveAudioFile(
Channels: int64(props.Channels),
Bitrate: int64(props.Bitrate),
FileSize: props.FileSize,
}); err != nil {
Basename: basename,
})
if err != nil {
return fmt.Errorf(
"could not save audio file to db: %w", err,
)
}
// Index in FTS5 search_index.
title := l.getRecordingName(tags, result.absolutePath)
artistName := tags.Artist
if artistName == "" {
artistName = "Unknown Artist"
}
album := tags.Album
if _, err := tx.ExecContext(
l.ctx,
`INSERT INTO search_index(rowid, file_path, title, artist, album)
VALUES (?, ?, ?, ?, ?)`,
af.ID, result.absolutePath, title, artistName, album,
); err != nil {
l.logger.Warn(
"could not index audio file in FTS",
"path", result.absolutePath,
"err", err,
)
}
l.logger.Debug(
"added audio file to library",
"path", result.absolutePath,
@@ -755,6 +799,7 @@ func (l *Library) saveAudioFile(
// updateAudioFileMetadata updates an existing audio file with extracted metadata.
func (l *Library) updateAudioFileMetadata(
q *sqlcgen.Queries,
tx *sql.Tx,
cache *entityCache,
metrics *ScanMetrics,
result importResult,
@@ -794,6 +839,50 @@ func (l *Library) updateAudioFileMetadata(
)
}
// Index in FTS5 search_index (delete old entry, insert new).
tags := result.tags
if tags == nil {
tags = &metadata.TrackMetadata{}
}
title := l.getRecordingName(tags, result.absolutePath)
artistName := tags.Artist
if artistName == "" {
artistName = "Unknown Artist"
}
album := tags.Album
if _, err := tx.ExecContext(
l.ctx,
`DELETE FROM search_index WHERE rowid = ?`,
result.existingFileID,
); err != nil {
l.logger.Warn(
"could not remove old FTS entry",
"id", result.existingFileID,
"err", err,
)
}
if _, err := tx.ExecContext(
l.ctx,
`INSERT INTO search_index(rowid, file_path, title, artist, album)
VALUES (?, ?, ?, ?, ?)`,
result.existingFileID,
result.absolutePath,
title,
artistName,
album,
); err != nil {
l.logger.Warn(
"could not index updated audio file in FTS",
"path", result.absolutePath,
"err", err,
)
}
l.logger.Debug(
"updated audio file metadata",
"path", result.absolutePath,
+183 -22
View File
@@ -1,6 +1,7 @@
package library
import (
"database/sql"
"errors"
"fmt"
"path/filepath"
@@ -48,6 +49,39 @@ func splitGenres(concatenated string) []string {
return strings.Split(concatenated, genreDelimiter)
}
// mapTrackRow converts raw database column values into a Track.
// This is shared by GetAllTracks, SearchTracks, and GetTracksByGenre
// to avoid tripling the row-mapping code.
func mapTrackRow(
filePath string,
lengthMs int64,
title, artistName string,
trackNumber, discNumber sql.NullInt64,
album, genre string,
year int64,
composer, fileType string,
sampleRate, bitDepth, channels, bitrate, fileSize int64,
) Track {
return Track{
TrackName: title,
ArtistName: artistName,
TrackLength: strconv.FormatInt(lengthMs, 10),
FilePath: filePath,
TrackNumber: trackNumber.Int64,
DiscNumber: discNumber.Int64,
Album: album,
Genre: splitGenres(genre),
Year: year,
Composer: composer,
FileType: fileType,
SampleRate: sampleRate,
BitDepth: bitDepth,
Channels: channels,
Bitrate: bitrate,
FileSize: fileSize,
}
}
// Artist represents an artist in the library.
type Artist struct {
ID int64
@@ -91,28 +125,24 @@ func (l *Library) GetAllTracks() ([]Track, error) {
tracks := make([]Track, 0, len(rows))
for _, row := range rows {
track := Track{
TrackName: row.Title,
ArtistName: row.ArtistName,
TrackLength: strconv.FormatInt(
row.LengthMilliseconds, 10,
),
FilePath: row.FilePath,
TrackNumber: row.TrackNumber.Int64,
DiscNumber: row.DiscNumber.Int64,
Album: row.Album,
Genre: splitGenres(row.Genre),
Year: row.Year,
Composer: row.Composer,
FileType: row.FileType,
SampleRate: row.SampleRate,
BitDepth: row.BitDepth,
Channels: row.Channels,
Bitrate: row.Bitrate,
FileSize: row.FileSize,
}
tracks = append(tracks, track)
tracks = append(tracks, mapTrackRow(
row.FilePath,
row.LengthMilliseconds,
row.Title,
row.ArtistName,
row.TrackNumber,
row.DiscNumber,
row.Album,
row.Genre,
row.Year,
row.Composer,
row.FileType,
row.SampleRate,
row.BitDepth,
row.Channels,
row.Bitrate,
row.FileSize,
))
}
l.logger.Info("formatted tracks", "count", len(tracks))
@@ -120,6 +150,56 @@ func (l *Library) GetAllTracks() ([]Track, error) {
return tracks, nil
}
// searchTrackLimit is the maximum number of results returned by
// a full-text search.
const searchTrackLimit = 200
// SearchTracks performs an FTS5 full-text search and returns
// matching tracks with full metadata.
func (l *Library) SearchTracks(
query string,
) ([]Track, error) {
rows, err := l.db.SearchFTSTracks(
query, searchTrackLimit,
)
if err != nil {
l.logger.Error(
"FTS track search failed",
"query", query,
"error", err,
)
return nil, fmt.Errorf(
"search tracks failed: %w", err,
)
}
tracks := make([]Track, 0, len(rows))
for _, row := range rows {
tracks = append(tracks, mapTrackRow(
row.FilePath,
row.LengthMilliseconds,
row.Title,
row.ArtistName,
row.TrackNumber,
row.DiscNumber,
row.Album,
row.Genre,
row.Year,
row.Composer,
row.FileType,
row.SampleRate,
row.BitDepth,
row.Channels,
row.Bitrate,
row.FileSize,
))
}
return tracks, nil
}
// GetAlbumTracks returns all tracks for a given album (release group), ordered by disc and track number.
func (l *Library) GetAlbumTracks(albumID int64) ([]Track, error) {
rows, err := l.db.Queries.GetAudioFilesByReleaseGroup(l.ctx, albumID)
@@ -280,3 +360,84 @@ func (l *Library) GetAlbumsByArtist(
return albums, nil
}
// GenreWithCount holds a genre name and its associated track count.
type GenreWithCount struct {
Name string `json:"Name"`
TrackCount int64 `json:"TrackCount"`
}
// GetTracksByGenre returns all tracks tagged with the given genre.
func (l *Library) GetTracksByGenre(
genreName string,
) ([]Track, error) {
rows, err := l.db.Queries.GetTracksByGenre(
l.ctx, genreName,
)
if err != nil {
l.logger.Error(
"could not retrieve tracks for genre",
"genre", genreName,
"error", err,
)
return nil, fmt.Errorf(
"could not get tracks for genre: %w", err,
)
}
tracks := make([]Track, 0, len(rows))
for _, row := range rows {
tracks = append(tracks, mapTrackRow(
row.FilePath,
row.LengthMilliseconds,
row.Title,
row.ArtistName,
row.TrackNumber,
row.DiscNumber,
row.Album,
row.Genre,
row.Year,
row.Composer,
row.FileType,
row.SampleRate,
row.BitDepth,
row.Channels,
row.Bitrate,
row.FileSize,
))
}
return tracks, nil
}
// GetAllGenresWithCounts returns all genres with their track counts.
func (l *Library) GetAllGenresWithCounts() (
[]GenreWithCount, error,
) {
rows, err := l.db.Queries.GetAllGenresWithCounts(
l.ctx,
)
if err != nil {
l.logger.Error(
"could not retrieve genres with counts",
"error", err,
)
return nil, fmt.Errorf(
"could not get genres: %w", err,
)
}
genres := make([]GenreWithCount, 0, len(rows))
for _, row := range rows {
genres = append(genres, GenreWithCount{
Name: row.Name,
TrackCount: row.TrackCount,
})
}
return genres, nil
}
+9
View File
@@ -160,6 +160,15 @@ func (l *Library) clearLibraryTables() error {
)
}
// Clear FTS5 search index.
if _, err := tx.ExecContext(
l.ctx, `DELETE FROM search_index`,
); err != nil {
return fmt.Errorf(
"could not clear search index: %w", err,
)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf(
"could not commit library clear transaction: %w", err,